diff --git a/IoListTestingWindow.ContextUx.cs b/IoListTestingWindow.ContextUx.cs index 1bba89d4..07a16b85 100644 --- a/IoListTestingWindow.ContextUx.cs +++ b/IoListTestingWindow.ContextUx.cs @@ -14,7 +14,7 @@ public partial class IoListTestingWindow private bool _selectedIedContextInstalled; public bool SelectedCanStartWorkflow => - SelectedIed != null && !IsPreparingIed && Session.CanStart; + SelectedIed != null && !SelectedIed.IsPreparing && Session.CanStart; public bool SelectedCanPause => IsSelectedSessionIed && Session.CanPause; @@ -32,27 +32,30 @@ public string SelectedStartWorkflowText if (SelectedIed == null) return "Select IED"; - if (_preparingIed != null) - { - return ReferenceEquals(_preparingIed, SelectedIed) - ? $"Connecting {SelectedIed.IedName}…" - : $"{_preparingIed.IedName} connecting…"; - } + if (SelectedIed.IsPreparing) + return $"Connecting {SelectedIed.IedName}…"; if (Session.IsSessionActive) { return IsSelectedSessionIed ? "IED session active" - : $"{Session.ActiveIed?.IedName ?? "Another IED"} active"; + : $"{Session.ActiveIed?.IedName ?? "Another IED"} FAT active"; } var enabled = EnabledPoints(SelectedIed); - if (enabled.Count > 0 && enabled.All(point => point.Runtime.State == IoTestPointState.Passed)) - return "Reconnect / Retest"; + var allPassed = enabled.Count > 0 && enabled.All(point => point.Runtime.State == IoTestPointState.Passed); + var hasCompleted = enabled.Any(point => point.Runtime.IsComplete); - return enabled.Any(point => point.Runtime.IsComplete) - ? "Connect & Continue IED" - : "Connect & Start IED"; + if (SelectedIed.IsLiveMonitoring) + { + if (allPassed) + return "Retest FAT"; + return hasCompleted ? "Continue FAT" : "Start FAT"; + } + + if (allPassed) + return "Reconnect / Retest"; + return hasCompleted ? "Connect & Continue IED" : "Connect & Start IED"; } } @@ -63,8 +66,8 @@ public string SelectedFooterStatusText if (SelectedIed == null) return "Select an imported IED."; - if (ReferenceEquals(_preparingIed, SelectedIed)) - return PreparationStatusText; + if (SelectedIed.IsPreparing) + return SelectedIed.PreparationStatusText; if (IsSelectedSessionIed && Session.State != IoTestSessionState.Idle) return Session.StatusText; @@ -152,12 +155,100 @@ private void AdoptWorkspacePreviewToggle() _printPreviewToggle.ToolTip = "Toggle the selected IED between signal evidence and native print preview"; } - private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e) + /// + /// Card-local connection action. Several different IED cards can run this method at + /// once; each device owns its own MainWindow connection workflow and model progress. + /// Evidence capture remains a separate, single-active session selected by Start FAT. + /// + private async void ConnectIed_Click(object sender, RoutedEventArgs e) { - if (IsPreparingIed) + var targetIed = (sender as FrameworkElement)?.DataContext as IoTestIedPlan; + if (targetIed == null || targetIed.IsPreparing) return; + var enabledReady = targetIed.TestPoints + .Where(point => point.TestEnabled && point.ImportReady) + .ToList(); + if (enabledReady.Count == 0) + { + ShowActionResult( + IoTestSessionActionResult.Failure("No import-ready IO-list signal is enabled for this IED."), + "IED connection scope is not ready"); + return; + } + + // For a continuation, connect only what still needs evidence. Completed rows keep + // their sealed evidence and do not have to exist in a replacement relay model. + // If every row is already complete, refresh the complete enabled scope instead. + var pendingScope = enabledReady.Where(point => !point.Runtime.IsComplete).ToList(); + IReadOnlyCollection connectionScope = pendingScope.Count > 0 + ? pendingScope + : enabledReady; + + if (ReferenceEquals(SelectedIed, targetIed)) + PreparationStatusText = $"Connecting {targetIed.IedName} · {targetIed.IpAddress}:102"; + RaisePreparationProperties(); + RaiseSelectedIedContextProperties(); + + try + { + if (Owner is not MainWindow engineeringWindow) + return; + + var progress = new Progress(message => + { + if (ReferenceEquals(SelectedIed, targetIed)) + PreparationStatusText = message; + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + }); + + var preparation = await PrepareIndependentIedConnectionAsync( + engineeringWindow, + targetIed, + progress, + connectionScope); + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + if (!preparation.Succeeded) + { + if (ReferenceEquals(SelectedIed, targetIed)) + PreparationStatusText = preparation.Message; + ShowActionResult(preparation, $"{targetIed.IedName} acquisition could not start"); + return; + } + + await CaptureTimeSyncEvidenceAfterPreparationAsync(engineeringWindow, targetIed); + if (ReferenceEquals(SelectedIed, targetIed)) + PreparationStatusText = $"{targetIed.IedName} live · ready for FAT evidence"; + Storage?.ScheduleSave(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + if (ReferenceEquals(SelectedIed, targetIed)) + PreparationStatusText = ex.Message; + MessageBox.Show( + this, + ex.Message, + $"Connect {targetIed.IedName} failed", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + targetIed.SetPreparationState(false, targetIed.LiveStatusText); + RaisePreparationProperties(); + RaiseSelectedIedContextProperties(); + } + } + + private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e) + { var selectedIed = SelectedIed; + if (selectedIed?.IsPreparing == true) + return; + if (selectedIed == null) { var missingSelection = IoTestSessionPreflight.Validate(null); @@ -180,7 +271,7 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e { var answer = MessageBox.Show( this, - $"All {completedPoints.Count} enabled rows for {selectedIed.IedName} already contain completed evidence.\n\nA normal Connect click will not erase them. Choose Yes only when you intentionally want to retest every completed row and replace its ON/OFF evidence.", + $"All {completedPoints.Count} enabled rows for {selectedIed.IedName} already contain completed evidence.\n\nA normal Start FAT click will not erase them. Choose Yes only when you intentionally want to retest every completed row and replace its ON/OFF evidence.", "Retest completed evidence?", MessageBoxButton.YesNo, MessageBoxImage.Warning, @@ -220,7 +311,6 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e { var progress = new Progress(message => { - selectedIed.SetPreparationState(true, message); PreparationStatusText = message; RaiseStatusProperties(); RaiseSelectedIedContextProperties(); @@ -278,6 +368,17 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e } } + private Task PrepareIndependentIedConnectionAsync( + MainWindow engineeringWindow, + IoTestIedPlan targetIed, + IProgress progress, + IReadOnlyCollection connectionScope) + => engineeringWindow.PrepareIoTestIedForFatAsync( + Project, + targetIed, + progress, + connectionScope); + private void ContextWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(SelectedIed) or nameof(IsPreparingIed) or nameof(PreparationStatusText)) @@ -289,6 +390,9 @@ private void ContextSession_PropertyChanged(object? sender, PropertyChangedEvent private void ContextIed_PropertyChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName == nameof(IoTestIedPlan.IsPreparing)) + RaisePreparationProperties(); + if (!ReferenceEquals(sender, SelectedIed)) return; diff --git a/IoListTestingWindow.MultiIedConnectionUx.cs b/IoListTestingWindow.MultiIedConnectionUx.cs new file mode 100644 index 00000000..f2f23b98 --- /dev/null +++ b/IoListTestingWindow.MultiIedConnectionUx.cs @@ -0,0 +1,83 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Installs a compact Connect action beside the selected IED workflow controls without +/// changing the evidence-session contract. The button follows SelectedIed, so the +/// operator can start IED B while IED A is still preparing; each card continues to show +/// its own progress and live state. +/// +public partial class IoListTestingWindow +{ + private static readonly bool MultiIedConnectionUxRegistered = RegisterMultiIedConnectionUx(); + private Button? _selectedIedConnectButton; + + private static bool RegisterMultiIedConnectionUx() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(MultiIedConnectionUx_Loaded)); + return true; + } + + private static void MultiIedConnectionUx_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window) + return; + + window.Dispatcher.BeginInvoke( + new Action(window.InstallSelectedIedConnectButton), + DispatcherPriority.Loaded); + } + + private void InstallSelectedIedConnectButton() + { + if (_selectedIedConnectButton != null || + LogicalTreeHelper.GetParent(WorkspacePreviewToggle) is not Panel actionBar) + { + return; + } + + var button = new Button + { + Style = FindResource("SoftButton") as Style, + Padding = new Thickness(11, 8, 11, 8), + Margin = new Thickness(0, 0, 6, 0), + ToolTip = "Connect or refresh the selected IED independently. Other IED connection workflows keep running." + }; + button.SetBinding( + FrameworkElement.DataContextProperty, + new Binding(nameof(SelectedIed)) { Source = this }); + button.SetBinding( + ContentControl.ContentProperty, + new Binding(nameof(IoTestIedPlan.ConnectionActionText))); + button.SetBinding( + UIElement.IsEnabledProperty, + new Binding(nameof(IoTestIedPlan.CanPrepareConnection))); + button.Click += ConnectIed_Click; + + var previewIndex = actionBar.Children.IndexOf(WorkspacePreviewToggle); + actionBar.Children.Insert(Math.Max(0, previewIndex + 1), button); + _selectedIedConnectButton = button; + Closed += MultiIedConnectionUx_Closed; + } + + private void MultiIedConnectionUx_Closed(object? sender, EventArgs e) + { + Closed -= MultiIedConnectionUx_Closed; + if (_selectedIedConnectButton == null) + return; + + _selectedIedConnectButton.Click -= ConnectIed_Click; + _selectedIedConnectButton = null; + } +} diff --git a/IoListTestingWindow.PrintPreview.cs b/IoListTestingWindow.PrintPreview.cs index 71cb50b8..e968eaf0 100644 --- a/IoListTestingWindow.PrintPreview.cs +++ b/IoListTestingWindow.PrintPreview.cs @@ -22,7 +22,6 @@ public partial class IoListTestingWindow private Button? _printPreviewToggle; private TextBlock? _printPreviewZoomText; private TextBlock? _printPreviewPageText; - private DispatcherTimer? _preparationStateGuard; protected override void OnContentRendered(EventArgs e) { @@ -31,7 +30,6 @@ protected override void OnContentRendered(EventArgs e) return; InstallPerIedPrintPreview(); - InstallPreparationStateGuard(); PropertyChanged += PrintPreviewWindow_PropertyChanged; Session.PropertyChanged += PrintPreviewSession_PropertyChanged; Closed += PrintPreviewWindow_Closed; @@ -441,22 +439,6 @@ private static DataTemplate CenteredTemplate( return new DataTemplate { VisualTree = text }; } - private void InstallPreparationStateGuard() - { - _preparationStateGuard = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(180) }; - _preparationStateGuard.Tick += (_, _) => ClearStalePreparationFlags(); - _preparationStateGuard.Start(); - } - - private void ClearStalePreparationFlags() - { - foreach (var ied in Project.Ieds) - { - if (ied.IsPreparing && !ReferenceEquals(ied, _preparingIed)) - ied.SetPreparationState(false, ied.LiveStatusText); - } - } - private void PrintPreviewWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) { if (_printPreviewActive && e.PropertyName == nameof(SelectedIed)) @@ -471,7 +453,6 @@ private void PrintPreviewSession_PropertyChanged(object? sender, PropertyChanged private void PrintPreviewWindow_Closed(object? sender, EventArgs e) { - _preparationStateGuard?.Stop(); PropertyChanged -= PrintPreviewWindow_PropertyChanged; Session.PropertyChanged -= PrintPreviewSession_PropertyChanged; Closed -= PrintPreviewWindow_Closed; diff --git a/IoListTestingWindow.RealPreparationProgress.cs b/IoListTestingWindow.RealPreparationProgress.cs index 98e6a6b2..3cc84ec8 100644 --- a/IoListTestingWindow.RealPreparationProgress.cs +++ b/IoListTestingWindow.RealPreparationProgress.cs @@ -60,7 +60,7 @@ private void PreparationProgressTimer_Tick(object? sender, EventArgs e) _preparationDisplayStates[ied] = state; } - var active = ied.IsPreparing || ReferenceEquals(_preparingIed, ied); + var active = ied.IsPreparing; if (active && !state.WasActive) state.Reset(); diff --git a/IoListTestingWindow.xaml.cs b/IoListTestingWindow.xaml.cs index 4861de0a..0012e2f2 100644 --- a/IoListTestingWindow.xaml.cs +++ b/IoListTestingWindow.xaml.cs @@ -10,7 +10,6 @@ namespace ArIED61850Tester; public partial class IoListTestingWindow : Window, INotifyPropertyChanged { private IoTestIedPlan? _selectedIed; - private IoTestIedPlan? _preparingIed; private string _preparationStatusText = string.Empty; public IoListTestingWindow() @@ -47,11 +46,13 @@ public IoTestIedPlan? SelectedIed _selectedIed = value; Raise(); Raise(nameof(SelectedIedSummary)); - Raise(nameof(CanStartWorkflow)); + RaisePreparationProperties(); } } - public bool IsPreparingIed => _preparingIed != null; + // Compatibility aggregate used for close/edit protection. It no longer blocks + // another IED from starting its own independent connection workflow. + public bool IsPreparingIed => Project.Ieds.Any(ied => ied.IsPreparing); public string PreparationStatusText { @@ -67,22 +68,23 @@ private set } } - public Visibility PreparationVisibility => IsPreparingIed ? Visibility.Visible : Visibility.Collapsed; - public string PreparationIedText => _preparingIed == null ? string.Empty : $"Preparing {_preparingIed.IedName}"; + public Visibility PreparationVisibility => SelectedIed?.IsPreparing == true ? Visibility.Visible : Visibility.Collapsed; + public string PreparationIedText => SelectedIed?.IsPreparing == true ? $"Preparing {SelectedIed.IedName}" : string.Empty; public bool CanStartWorkflow => - SelectedIed != null && !IsPreparingIed && Session.CanStart; + SelectedIed != null && !SelectedIed.IsPreparing && Session.CanStart; - // Explorer navigation stays available while one IED is connecting or another FAT - // session is running. This is inspection-only; the active evidence scope remains - // pinned to Session.ActiveIed. + // Explorer navigation stays available while one or more IEDs are connecting or a + // FAT evidence session is running. Each IED card owns its own connection progress. public bool CanSelectIed => true; + // Keep plan mutation frozen while any network preparation is consuming the selected + // FAT scope, or while the evidence controller owns a session. public bool CanEditPlan => !IsPreparingIed && Session.CanEditPlan; public string StartWorkflowText => - IsPreparingIed ? $"Connecting {_preparingIed!.IedName}…" : "Connect & Start IED"; + SelectedIed?.IsPreparing == true ? $"Connecting {SelectedIed.IedName}…" : "Connect & Start IED"; public string ProjectSummary => $"{Project.Ieds.Count} IED · {Project.SignalCount} points · {Project.LiveBoundSignalCount} live"; @@ -91,18 +93,18 @@ private set ? "Select an imported IED" : $"{SelectedIed.IpAddress} · {SelectedIed.EnabledCount} test points · {SelectedIed.LiveStatusText}"; - public string FooterStatusText => IsPreparingIed - ? PreparationStatusText + public string FooterStatusText => SelectedIed?.IsPreparing == true + ? SelectedIed.PreparationStatusText : Session.StatusText; public event PropertyChangedEventHandler? PropertyChanged; private async void StartSession_Click(object sender, RoutedEventArgs e) { - if (IsPreparingIed) + var selectedIed = SelectedIed; + if (selectedIed?.IsPreparing == true) return; - var selectedIed = SelectedIed; var preflight = IoTestSessionPreflight.Validate(selectedIed); if (!preflight.Succeeded) { @@ -110,16 +112,17 @@ private async void StartSession_Click(object sender, RoutedEventArgs e) return; } - SetPreparingIed(selectedIed!, $"Connecting {selectedIed!.IedName} · {selectedIed.IpAddress}:102"); + PreparationStatusText = $"Connecting {selectedIed!.IedName} · {selectedIed.IpAddress}:102"; + RaisePreparationProperties(); try { if (Owner is MainWindow engineeringWindow) { var progress = new Progress(message => { - selectedIed.SetPreparationState(true, message); PreparationStatusText = message; RaiseStatusProperties(); + RaisePreparationProperties(); }); var preparation = await engineeringWindow.PrepareIoTestIedForFatAsync( Project, @@ -160,7 +163,7 @@ private async void StartSession_Click(object sender, RoutedEventArgs e) finally { selectedIed.SetPreparationState(false, selectedIed.LiveStatusText); - SetPreparingIed(null, string.Empty); + RaisePreparationProperties(); } } @@ -347,9 +350,10 @@ private void Window_Closing(object? sender, CancelEventArgs e) { if (IsPreparingIed) { + var activeNames = string.Join(", ", Project.Ieds.Where(ied => ied.IsPreparing).Select(ied => ied.IedName)); MessageBox.Show( this, - $"ARSAS is still preparing {_preparingIed!.IedName}. You can inspect other IEDs while it runs, but wait for acquisition setup to finish before closing this workspace.", + $"ARSAS is still preparing {activeNames}. You can inspect or connect other IEDs while these independent workflows run, but finish preparation before closing this workspace.", "IED preparation in progress", MessageBoxButton.OK, MessageBoxImage.Information); @@ -404,8 +408,14 @@ private void Session_PropertyChanged(object? sender, PropertyChangedEventArgs e) private void SetPreparingIed(IoTestIedPlan? ied, string status) { - _preparingIed = ied; + // Retained as a lightweight UI refresh hook for older call paths. Preparation + // ownership now lives on IoTestIedPlan, so parallel IEDs never share one lock. PreparationStatusText = status; + RaisePreparationProperties(); + } + + private void RaisePreparationProperties() + { Raise(nameof(IsPreparingIed)); Raise(nameof(PreparationVisibility)); Raise(nameof(PreparationIedText)); @@ -418,9 +428,7 @@ private void SetPreparingIed(IoTestIedPlan? ied, string status) private void RaiseStatusProperties() { - Raise(nameof(CanStartWorkflow)); - Raise(nameof(CanSelectIed)); - Raise(nameof(CanEditPlan)); + RaisePreparationProperties(); Raise(nameof(ProjectSummary)); Raise(nameof(SelectedIedSummary)); Raise(nameof(FooterStatusText)); diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index bdb4e163..5e3233a1 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -9,20 +9,25 @@ public partial class MainWindow private readonly IoTestSignalSelectionService _ioTestSignalSelectionService = new(); /// - /// Prepares one imported IO-list IED for a monitoring-only FAT session. The workbook - /// supplies the endpoint and exact signal scope, so the operator does not need to - /// duplicate Add IED and signal-selection work in the engineering window. + /// Prepares one imported IO-list IED for monitoring-only FAT acquisition. Calls for + /// different IEDs are independent and may overlap; the IED model itself owns the + /// preparation flag, while live binding is deliberately scoped to this IED only. /// internal async Task PrepareIoTestIedForFatAsync( IoTestProject project, IoTestIedPlan ied, - IProgress? progress = null) + IProgress? progress = null, + IReadOnlyCollection? requestedPointsOverride = null) { ArgumentNullException.ThrowIfNull(project); ArgumentNullException.ThrowIfNull(ied); - var requestedPoints = ied.TestPoints + if (ied.IsPreparing) + return IoTestSessionActionResult.Failure($"{ied.IedName} is already being prepared for FAT acquisition."); + + var requestedPoints = (requestedPointsOverride ?? ied.TestPoints) .Where(point => point.TestEnabled && point.ImportReady) + .Distinct() .ToList(); if (requestedPoints.Count == 0) return IoTestSessionActionResult.Failure("No import-ready IO-list signal is enabled for this IED."); @@ -49,7 +54,7 @@ void ReportProgress(string message) Port = 102, AllowDynamicDataSetWrites = true, Status = "IO FAT ready to connect", - Detail = "Connect & Start will discover the live model and arm report-first acquisition for the imported FAT scope." + Detail = "Connect will discover the live model and arm report-first acquisition for this IED's imported FAT scope." }; Devices.Add(device); RaiseWorkspaceCounts(); @@ -113,7 +118,7 @@ void ReportProgress(string message) if (!connected) { - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); return IoTestSessionActionResult.Failure( $"ARSAS could not connect to {ied.IedName} at {ied.IpAddress}:102. Open Diagnostics for the MMS association or discovery error."); } @@ -124,7 +129,7 @@ void ReportProgress(string message) await StopDeviceConnectionAsync(device); if (!await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false)) { - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); return IoTestSessionActionResult.Failure($"Full live-model discovery failed for {ied.IedName}."); } } @@ -145,7 +150,18 @@ void ReportProgress(string message) } ReportProgress($"Matching {requestedPoints.Count} workbook signal(s)"); - var selection = _ioTestSignalSelectionService.Resolve(ied, device); + var selection = _ioTestSignalSelectionService.Resolve( + new IoTestIedPlan + { + IedName = ied.IedName, + IpAddress = ied.IpAddress, + IedRole = ied.IedRole, + Location = ied.Location, + VoltageLevel = ied.VoltageLevel, + Switchgear = ied.Switchgear, + TestPoints = requestedPoints + }, + device); if (!selection.Succeeded && selection.CanRetryWithFreshDiscovery) { ReportProgress("Refreshing live model once · saved model missed workbook points"); @@ -156,7 +172,7 @@ void ReportProgress(string message) if (!await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false)) { - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); return IoTestSessionActionResult.Failure($"Live-model refresh failed for {ied.IedName}."); } @@ -168,12 +184,23 @@ void ReportProgress(string message) "Revalidating after fresh live-model discovery.", device.DeviceId); } - selection = _ioTestSignalSelectionService.Resolve(ied, device); + selection = _ioTestSignalSelectionService.Resolve( + new IoTestIedPlan + { + IedName = ied.IedName, + IpAddress = ied.IpAddress, + IedRole = ied.IedRole, + Location = ied.Location, + VoltageLevel = ied.VoltageLevel, + Switchgear = ied.Switchgear, + TestPoints = requestedPoints + }, + device); } if (!selection.Succeeded) { - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); return IoTestSessionActionResult.Failure( $"ARSAS could not prepare the imported FAT scope safely. {selection.Message}"); } @@ -212,7 +239,7 @@ void ReportProgress(string message) device.RefreshComputed(); RaiseWorkspaceCounts(); - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); var allRequestedPointsLive = requestedPoints.All(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady); @@ -227,20 +254,20 @@ void ReportProgress(string message) ReportProgress("Arming static RCB first · dynamic DataSet/URCB for uncovered points"); if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false)) { - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); return IoTestSessionActionResult.Failure( $"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope."); } } var acquisition = await SettleIoFatReportPriorityAsync( - project, + ied, requestedPoints, device, ReportProgress, allowSingleRestart: true); - var binding = _ioTestLiveBindingService.Bind(project, Devices); + var binding = _ioTestLiveBindingService.BindIed(ied, Devices); var liveCount = requestedPoints.Count(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady); if (liveCount != requestedPoints.Count) @@ -266,18 +293,18 @@ void ReportProgress(string message) AddLog( acquisition.PollingCount == 0 ? "INFO" : "WARN", "IO Testing", - $"{message}. Acquisition policy: configured RCB → temporary dynamic DataSet/URCB → bounded MMS verification/fallback. No process control commands are enabled. Project live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); + $"{message}. Acquisition policy: configured RCB → temporary dynamic DataSet/URCB → bounded MMS verification/fallback. No process control commands are enabled. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); ReportProgress(message); return IoTestSessionActionResult.Success(message); } catch (OperationCanceledException) { - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); return IoTestSessionActionResult.Failure($"Connection preparation for {ied.IedName} was cancelled."); } catch (Exception ex) { - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); AddLog("ERROR", "IO Testing", $"{ied.IedName} automatic preparation failed: {ex}"); MarkDiagnosticAlert(); return IoTestSessionActionResult.Failure( @@ -292,14 +319,14 @@ void ReportProgress(string message) } private async Task SettleIoFatReportPriorityAsync( - IoTestProject project, + IoTestIedPlan ied, IReadOnlyCollection requestedPoints, Iec61850MonitorDevice device, Action reportProgress, bool allowSingleRestart) { var first = await ObserveIoFatAcquisitionAsync( - project, + ied, requestedPoints, device, reportProgress, @@ -320,7 +347,7 @@ private async Task SettleIoFatReportPriorityAsync( return first; return await ObserveIoFatAcquisitionAsync( - project, + ied, requestedPoints, device, reportProgress, @@ -328,7 +355,7 @@ private async Task SettleIoFatReportPriorityAsync( } private async Task ObserveIoFatAcquisitionAsync( - IoTestProject project, + IoTestIedPlan ied, IReadOnlyCollection requestedPoints, Iec61850MonitorDevice device, Action reportProgress, @@ -341,7 +368,7 @@ private async Task ObserveIoFatAcquisitionAsync( while (DateTime.UtcNow < deadline && device.IsMonitoring) { await Task.Delay(120); - _ioTestLiveBindingService.Bind(project, Devices); + _ioTestLiveBindingService.BindIed(ied, Devices); last = ReadIoFatAcquisitionSummary(requestedPoints, device); if (!announced) diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index 34961c13..091c6b17 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -340,6 +340,8 @@ private set if (!Set(ref _isPreparing, value)) return; Raise(nameof(CardStateText)); + Raise(nameof(ConnectionActionText)); + Raise(nameof(CanPrepareConnection)); } } @@ -359,6 +361,7 @@ private set if (!Set(ref _isLiveConnected, value)) return; Raise(nameof(CardStateText)); + Raise(nameof(ConnectionActionText)); } } @@ -371,12 +374,25 @@ private set if (!Set(ref _isLiveMonitoring, value)) return; Raise(nameof(CardStateText)); + Raise(nameof(ConnectionActionText)); } } [JsonIgnore] public string CardStateText => IsPreparing ? "CONNECTING" : IsLiveMonitoring ? "LIVE" : IsLiveConnected ? "READY" : "OFFLINE"; + [JsonIgnore] + public string ConnectionActionText => IsPreparing + ? "Connecting…" + : IsLiveMonitoring + ? "Refresh" + : IsLiveConnected + ? "Prepare" + : "Connect"; + + [JsonIgnore] + public bool CanPrepareConnection => !IsPreparing; + public int EnabledCount => TestPoints.Count(point => point.TestEnabled); public int PassedCount => TestPoints.Count(point => point.Runtime.State == IoTestPointState.Passed); public int ReviewCount => TestPoints.Count(point => point.Runtime.State == IoTestPointState.Review); @@ -404,6 +420,8 @@ public void SetPreparationState(bool isPreparing, string? status = null) if (!isPreparing && string.IsNullOrWhiteSpace(status)) PreparationStatusText = string.Empty; Raise(nameof(CardStateText)); + Raise(nameof(ConnectionActionText)); + Raise(nameof(CanPrepareConnection)); } public void InitializeRuntimeNotifications() @@ -475,4 +493,4 @@ public void InitializeRuntimeNotifications() foreach (var ied in Ieds) ied.InitializeRuntimeNotifications(); } -} \ No newline at end of file +} diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index 63c6f926..a7534ff7 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -19,14 +19,34 @@ public IoTestLiveBindingSummary Bind( { ArgumentNullException.ThrowIfNull(project); ArgumentNullException.ThrowIfNull(devices); + return BindPlans(project.Ieds, devices.ToList(), project.SignalCount); + } - var deviceList = devices.ToList(); + /// + /// Rebinds one FAT IED only. Connection preparation can run for several IEDs at the + /// same time; a slow/offline IED must never clear another IED's already-proven live + /// state just because a full-project refresh happened during an await boundary. + /// + public IoTestLiveBindingSummary BindIed( + IoTestIedPlan ied, + IEnumerable devices) + { + ArgumentNullException.ThrowIfNull(ied); + ArgumentNullException.ThrowIfNull(devices); + return BindPlans(new[] { ied }, devices.ToList(), ied.TestPoints.Count); + } + + private static IoTestLiveBindingSummary BindPlans( + IReadOnlyCollection plans, + IReadOnlyCollection deviceList, + int signalCount) + { var deviceBoundCount = 0; var signalBoundCount = 0; var livePointCount = 0; var missingSignalCount = 0; - foreach (var iedPlan in project.Ieds) + foreach (var iedPlan in plans) { var device = FindDevice(iedPlan, deviceList); if (device == null) @@ -79,9 +99,9 @@ public IoTestLiveBindingSummary Bind( } return new IoTestLiveBindingSummary( - project.Ieds.Count, + plans.Count, deviceBoundCount, - project.SignalCount, + signalCount, signalBoundCount, livePointCount, missingSignalCount); diff --git a/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs new file mode 100644 index 00000000..8931220d --- /dev/null +++ b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs @@ -0,0 +1,141 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatMultiIedConnectionRegressionTests +{ + [Fact] + public void BindIed_DoesNotOverwriteAnotherIedsProvenBinding() + { + var service = new IoTestLiveBindingService(); + var targetPoint = Point("P-A", "IED_A", "192.168.81.10", "IED_ALD0/GGIO1.Ind1.stVal"); + var target = Ied("IED_A", "192.168.81.10", targetPoint); + var untouchedPoint = Point("P-B", "IED_B", "192.168.81.11", "IED_BLD0/GGIO1.Ind2.stVal"); + var untouched = Ied("IED_B", "192.168.81.11", untouchedPoint); + + untouched.ApplyLiveDeviceBinding("stable-device", "Monitoring · report", true, true); + untouchedPoint.ApplyLiveBinding( + IoTestLiveBindingState.LivePointReady, + "Previously proven live binding", + "stable-device", + untouchedPoint.ObjectReference); + + var targetDevice = new Iec61850MonitorDevice + { + Name = "IED_A", + SclIedName = "IED_A", + IpAddress = "192.168.81.10", + Port = 102 + }; + targetDevice.Signals.Add(new SignalDefinition + { + Name = "Ind1", + ObjectReference = targetPoint.ObjectReference, + FunctionalConstraint = "ST" + }); + + var result = service.BindIed(target, new[] { targetDevice }); + + Assert.Equal(1, result.IedCount); + Assert.Equal("stable-device", untouched.LiveDeviceId); + Assert.True(untouched.IsLiveConnected); + Assert.True(untouched.IsLiveMonitoring); + Assert.Equal(IoTestLiveBindingState.LivePointReady, untouchedPoint.LiveBindingState); + Assert.Equal("stable-device", untouchedPoint.LiveDeviceId); + } + + [Fact] + public void IedConnectionAction_IsOwnedByEachIed() + { + var a = Ied("IED_A", "192.168.81.10", Point("P-A", "IED_A", "192.168.81.10", "IED_ALD0/GGIO1.Ind1.stVal")); + var b = Ied("IED_B", "192.168.81.11", Point("P-B", "IED_B", "192.168.81.11", "IED_BLD0/GGIO1.Ind2.stVal")); + + Assert.Equal("Connect", a.ConnectionActionText); + Assert.Equal("Connect", b.ConnectionActionText); + + a.SetPreparationState(true, "Connecting A"); + + Assert.False(a.CanPrepareConnection); + Assert.Equal("Connecting…", a.ConnectionActionText); + Assert.True(b.CanPrepareConnection); + Assert.Equal("Connect", b.ConnectionActionText); + + b.ApplyLiveDeviceBinding("device-b", "Monitoring", true, true); + Assert.Equal("Refresh", b.ConnectionActionText); + } + + [Fact] + public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsolation() + { + var autoConnect = ReadRepoFile("MainWindow.IoTesting.AutoConnect.cs"); + var contextUx = ReadRepoFile("IoListTestingWindow.ContextUx.cs"); + var progressUx = ReadRepoFile("IoListTestingWindow.RealPreparationProgress.cs"); + var connectUx = ReadRepoFile("IoListTestingWindow.MultiIedConnectionUx.cs"); + var printPreview = ReadRepoFile("IoListTestingWindow.PrintPreview.cs"); + var session = ReadRepoFile("Services/IoTesting/IoTestSessionController.cs"); + + Assert.Contains("_ioTestLiveBindingService.BindIed(ied, Devices)", autoConnect, StringComparison.Ordinal); + Assert.DoesNotContain("_ioTestLiveBindingService.Bind(project, Devices)", autoConnect, StringComparison.Ordinal); + Assert.Contains("requestedPointsOverride", autoConnect, StringComparison.Ordinal); + Assert.Contains("private async void ConnectIed_Click", contextUx, StringComparison.Ordinal); + Assert.Contains("targetIed.IsPreparing", contextUx, StringComparison.Ordinal); + Assert.Contains("SelectedIed.IsPreparing", contextUx, StringComparison.Ordinal); + Assert.Contains("var active = ied.IsPreparing;", progressUx, StringComparison.Ordinal); + Assert.DoesNotContain("_preparingIed", progressUx, StringComparison.Ordinal); + Assert.DoesNotContain("_preparingIed", printPreview, StringComparison.Ordinal); + Assert.DoesNotContain("ClearStalePreparationFlags", printPreview, StringComparison.Ordinal); + Assert.Contains("Other IED connection workflows keep running", connectUx, StringComparison.Ordinal); + Assert.Contains("new Binding(nameof(SelectedIed))", connectUx, StringComparison.Ordinal); + + // P1 parallelizes connection/monitoring only. The evidence controller remains + // intentionally single-active so relay transitions cannot enter the wrong journal. + Assert.Contains("Stop the active FAT session before starting another IED.", session, StringComparison.Ordinal); + Assert.Contains("entry.DeviceId.Equals(activeDevice.DeviceId", session, StringComparison.Ordinal); + } + + private static IoTestPointPlan Point(string id, string iedName, string ipAddress, string reference) => new() + { + TestPointId = id, + IedName = iedName, + IpAddress = ipAddress, + SignalName = id, + ObjectReference = reference, + FunctionalConstraint = "ST", + ExpectedOnText = "ON", + ExpectedOffText = "OFF", + LogicalDevice = iedName + "LD0", + LogicalNode = "GGIO1", + DataObject = "Ind1", + DataAttribute = "stVal", + SourceIecReference = reference, + EventLogSearchReference = reference, + ReportDisplayReference = reference + " [ST]", + TestEnabled = true, + ImportReady = true + }; + + private static IoTestIedPlan Ied(string name, string ipAddress, params IoTestPointPlan[] points) => new() + { + IedName = name, + IpAddress = ipAddress, + IedRole = "Protection IED", + TestPoints = points.ToList() + }; + + private static string ReadRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return File.ReadAllText(candidate).Replace("\r\n", "\n", StringComparison.Ordinal); + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index 5b0ef79e..9cd08fdf 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -148,11 +148,14 @@ public void SignalGrid_CentersOperationalColumnsAndUsesRequestedEvidenceColors() public void PreviewKeepsProgressOnIedCardOnly() { var previewSource = File.ReadAllText(FindRepoFile("IoListTestingWindow.PrintPreview.cs")); + var progressSource = File.ReadAllText(FindRepoFile("IoListTestingWindow.RealPreparationProgress.cs")); var xaml = File.ReadAllText(FindRepoFile("IoListTestingWindow.xaml")); Assert.Contains("RemoveMainPreparationSurface", previewSource, StringComparison.Ordinal); Assert.Contains("workspaceGrid.Children.Remove(preparationSurface)", previewSource, StringComparison.Ordinal); - Assert.Contains("ClearStalePreparationFlags", previewSource, StringComparison.Ordinal); + Assert.DoesNotContain("ClearStalePreparationFlags", previewSource, StringComparison.Ordinal); + Assert.DoesNotContain("_preparingIed", previewSource, StringComparison.Ordinal); + Assert.Contains("var active = ied.IsPreparing;", progressSource, StringComparison.Ordinal); Assert.Contains("CardProgress", xaml, StringComparison.Ordinal); }