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);
}