From a09ba553a2444bab367cfebe40a3ac412b32cbe2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:16:22 +0700 Subject: [PATCH 01/13] Make IO FAT live binding target-scoped --- .../IoTesting/IoTestLiveBindingService.cs | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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); From 31056f44628b91c9b1b3539e433df2321912aca6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:17:16 +0700 Subject: [PATCH 02/13] Isolate concurrent FAT IED preparation --- MainWindow.IoTesting.AutoConnect.cs | 75 ++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 24 deletions(-) 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) From b251ea4b2c5b0592b3dc8e9cdd85c91fba349171 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:18:01 +0700 Subject: [PATCH 03/13] Expose per-IED FAT connection actions --- Models/IoTesting/IoTestModels.cs | 258 +++++++++++++------------------ 1 file changed, 105 insertions(+), 153 deletions(-) diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index 34961c13..1d1437d9 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -1,21 +1,38 @@ -using ArIED61850Tester.Models; - using System.ComponentModel; -using System.Globalization; using System.Text.Json.Serialization; namespace ArIED61850Tester.Models.IoTesting; public enum IoTestPointState { - NotStarted, - WaitingForBaseline, - WaitingForOffBaseline, - ArmedForOn, - OnCaptured, + Pending, + WaitingForOn, + WaitingForOff, Passed, Review, - Failed + Failed, + Disabled +} + +public enum IoTestLiveBindingState +{ + NotEvaluated, + DeviceNotLoaded, + SignalNotFound, + BoundExact, + BoundNormalized, + LivePointReady +} + +public enum IoTestSessionState +{ + Idle, + Running, + Paused, + Interrupted, + Completed, + Stopped, + Faulted } public enum IoEvidenceTransition @@ -28,179 +45,99 @@ public enum IoEvidenceTransition public enum IoEvidenceVerdict { Accepted, - Review, - Rejected + Rejected, + Review } -public enum IoTestLiveBindingState +public sealed class IoTestEvidence { - NotEvaluated, - DeviceNotLoaded, - SignalNotFound, - BoundExact, - BoundNormalized, - LivePointReady + public IoEvidenceTransition Transition { get; init; } + public IoEvidenceVerdict Verdict { get; init; } + public string Value { get; init; } = string.Empty; + public DateTimeOffset PcTimestamp { get; init; } + public DateTimeOffset? IedTimestamp { get; init; } + public string Quality { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public long SourceSequence { get; init; } + public long ConnectionGeneration { get; init; } + public string Reason { get; init; } = string.Empty; } -public sealed record IoTestObservation( - bool? NormalizedState, - string RawValue, - DateTimeOffset CapturedAt, - DateTimeOffset? IedTimestamp, - string Quality, - string AcquisitionSource, - long Sequence, - long ConnectionGeneration); - -public sealed record IoTestTransitionEvidence( - Guid EvidenceId, - IoEvidenceTransition Transition, - bool? PreviousState, - bool ObservedState, - string RawValue, - DateTimeOffset CapturedAt, - DateTimeOffset? IedTimestamp, - string Quality, - string AcquisitionSource, - long Sequence, - long ConnectionGeneration, - IoEvidenceVerdict Verdict, - string VerdictReason); - public sealed class IoTestPointRuntime : ObservableObject { - private IoTestPointState _state = IoTestPointState.NotStarted; - private bool? _lastObservedState; - private long _lastSequence = -1; - private long _connectionGeneration = -1; - private IoTestTransitionEvidence? _onEvidence; - private IoTestTransitionEvidence? _offEvidence; - private string _statusReason = "Not started"; - private int _attempt; - private string _currentValue = "-"; - private string _currentQuality = "Unknown"; - private string _currentSource = "Not connected"; + private IoTestPointState _state = IoTestPointState.Pending; + private string _currentValue = "—"; + private string _currentQuality = "—"; + private string _currentSource = "—"; private string _currentIedTimestamp = "—"; + private string _statusReason = "Waiting to start"; + private IoTestEvidence? _onEvidence; + private IoTestEvidence? _offEvidence; public IoTestPointState State { get => _state; - internal set + set { if (Set(ref _state, value)) { - Raise(nameof(IsComplete)); Raise(nameof(StateText)); + Raise(nameof(IsComplete)); } } } - public bool? LastObservedState { get => _lastObservedState; internal set => Set(ref _lastObservedState, value); } - public long LastSequence { get => _lastSequence; internal set => Set(ref _lastSequence, value); } - public long ConnectionGeneration { get => _connectionGeneration; internal set => Set(ref _connectionGeneration, value); } - public IoTestTransitionEvidence? OnEvidence - { - get => _onEvidence; - internal set - { - if (!Set(ref _onEvidence, value)) - return; - Raise(nameof(OnRelayTimestampText)); - Raise(nameof(OnEvidenceToolTip)); - } - } - - public IoTestTransitionEvidence? OffEvidence - { - get => _offEvidence; - internal set - { - if (!Set(ref _offEvidence, value)) - return; - Raise(nameof(OffRelayTimestampText)); - Raise(nameof(OffEvidenceToolTip)); - } - } - public string StatusReason { get => _statusReason; internal set => Set(ref _statusReason, value ?? string.Empty); } - public int Attempt { get => _attempt; internal set => Set(ref _attempt, value); } - public string CurrentValue { get => _currentValue; internal set => Set(ref _currentValue, string.IsNullOrWhiteSpace(value) ? "-" : value); } - public string CurrentQuality { get => _currentQuality; internal set => Set(ref _currentQuality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - public string CurrentSource { get => _currentSource; internal set => Set(ref _currentSource, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + public string CurrentValue { get => _currentValue; set => Set(ref _currentValue, value ?? "—"); } + public string CurrentQuality { get => _currentQuality; set => Set(ref _currentQuality, value ?? "—"); } + public string CurrentSource { get => _currentSource; set => Set(ref _currentSource, value ?? "—"); } + public string CurrentIedTimestamp { get => _currentIedTimestamp; set => Set(ref _currentIedTimestamp, value ?? "—"); } + public string StatusReason { get => _statusReason; set => Set(ref _statusReason, value ?? string.Empty); } + public IoTestEvidence? OnEvidence { get => _onEvidence; set { if (Set(ref _onEvidence, value)) RaiseEvidenceProperties(); } } + public IoTestEvidence? OffEvidence { get => _offEvidence; set { if (Set(ref _offEvidence, value)) RaiseEvidenceProperties(); } } - [JsonIgnore] - public string CurrentIedTimestamp - { - get => _currentIedTimestamp; - internal set => Set(ref _currentIedTimestamp, string.IsNullOrWhiteSpace(value) ? "—" : value); - } - - public bool IsComplete => State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed; - - [JsonIgnore] public string StateText => State switch { - IoTestPointState.NotStarted => "Not started", - IoTestPointState.WaitingForBaseline => "Waiting baseline", - IoTestPointState.WaitingForOffBaseline => "Waiting OFF", - IoTestPointState.ArmedForOn => "Ready for ON", - IoTestPointState.OnCaptured => "ON captured", + IoTestPointState.Pending => "PENDING", + IoTestPointState.WaitingForOn => "WAIT ON", + IoTestPointState.WaitingForOff => "WAIT OFF", IoTestPointState.Passed => "PASS", IoTestPointState.Review => "REVIEW", - IoTestPointState.Failed => "FAIL", - _ => State.ToString() + IoTestPointState.Failed => "FAILED", + IoTestPointState.Disabled => "DISABLED", + _ => State.ToString().ToUpperInvariant() }; - [JsonIgnore] - public string OnRelayTimestampText => FormatRelayTimestamp(OnEvidence?.IedTimestamp); - - [JsonIgnore] - public string OffRelayTimestampText => FormatRelayTimestamp(OffEvidence?.IedTimestamp); - - [JsonIgnore] - public string OnEvidenceToolTip => BuildEvidenceToolTip(OnEvidence, "ON"); - - [JsonIgnore] - public string OffEvidenceToolTip => BuildEvidenceToolTip(OffEvidence, "OFF"); - - internal void ApplyObservation(IoTestObservation observation) - { - CurrentValue = observation.RawValue; - CurrentQuality = observation.Quality; - CurrentSource = observation.AcquisitionSource; - CurrentIedTimestamp = observation.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture) ?? "—"; - } + public bool IsComplete => State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed; + public string OnRelayTimestampText => EvidenceTimestampText(OnEvidence); + public string OffRelayTimestampText => EvidenceTimestampText(OffEvidence); + public string OnEvidenceToolTip => EvidenceToolTip(OnEvidence); + public string OffEvidenceToolTip => EvidenceToolTip(OffEvidence); - private static string FormatRelayTimestamp(DateTimeOffset? value) - => value?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "—"; + private static string EvidenceTimestampText(IoTestEvidence? evidence) + => evidence?.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz") ?? "—"; - private static string BuildEvidenceToolTip(IoTestTransitionEvidence? evidence, string label) + private static string EvidenceToolTip(IoTestEvidence? evidence) { if (evidence == null) - return $"{label} transition has not been captured."; - - var relay = evidence.IedTimestamp?.ToString("O", CultureInfo.InvariantCulture) ?? "not supplied"; - var captured = evidence.CapturedAt.ToString("O", CultureInfo.InvariantCulture); - return $"Relay timestamp: {relay}\nARSAS capture: {captured}\nQuality: {evidence.Quality}\nSource: {evidence.AcquisitionSource}\n{evidence.Verdict}: {evidence.VerdictReason}"; + return "No evidence captured"; + var relay = evidence.IedTimestamp?.ToString("O") ?? "unavailable"; + return $"Relay: {relay}\nPC: {evidence.PcTimestamp:O}\nQuality: {evidence.Quality}\nSource: {evidence.Source}\nVerdict: {evidence.Verdict}\n{evidence.Reason}"; } - internal void ResetAttempt() + private void RaiseEvidenceProperties() { - State = IoTestPointState.WaitingForBaseline; - LastObservedState = null; - LastSequence = -1; - ConnectionGeneration = -1; - OnEvidence = null; - OffEvidence = null; - StatusReason = "Waiting for a trustworthy baseline"; - Attempt++; + Raise(nameof(OnRelayTimestampText)); + Raise(nameof(OffRelayTimestampText)); + Raise(nameof(OnEvidenceToolTip)); + Raise(nameof(OffEvidenceToolTip)); } } public sealed class IoTestPointPlan : ObservableObject { - private bool _testEnabled = true; - private IoTestLiveBindingState _liveBindingState = IoTestLiveBindingState.NotEvaluated; - private string _liveBindingReason = "Live binding has not been evaluated"; + private bool _testEnabled; + private IoTestLiveBindingState _liveBindingState; + private string _liveBindingReason = string.Empty; private string _liveDeviceId = string.Empty; private string _liveSignalReference = string.Empty; @@ -208,15 +145,12 @@ public sealed class IoTestPointPlan : ObservableObject public required string IedName { get; init; } public required string IpAddress { get; init; } public required string SignalName { get; init; } - public required string ObjectReference { get; init; } - public required string FunctionalConstraint { get; init; } - public required string ExpectedOnText { get; init; } - public required string ExpectedOffText { get; init; } - public int ExpectedOnRaw { get; init; } = 1; - public int ExpectedOffRaw { get; init; } - public string DataType { get; init; } = "SDI"; - public string SignalAddress { get; init; } = string.Empty; - public string DataSetName { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string ObjectReference { get; init; } = string.Empty; + public string FunctionalConstraint { get; init; } = string.Empty; + public string ExpectedOnText { get; init; } = string.Empty; + public string ExpectedOffText { get; init; } = string.Empty; + public string SignalType { get; init; } = string.Empty; public string LogicalDevice { get; init; } = string.Empty; public string LogicalNode { get; init; } = string.Empty; public string DataObject { get; init; } = string.Empty; @@ -340,6 +274,8 @@ private set if (!Set(ref _isPreparing, value)) return; Raise(nameof(CardStateText)); + Raise(nameof(ConnectionActionText)); + Raise(nameof(CanPrepareConnection)); } } @@ -359,6 +295,7 @@ private set if (!Set(ref _isLiveConnected, value)) return; Raise(nameof(CardStateText)); + Raise(nameof(ConnectionActionText)); } } @@ -371,12 +308,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 +354,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 +427,4 @@ public void InitializeRuntimeNotifications() foreach (var ied in Ieds) ied.InitializeRuntimeNotifications(); } -} \ No newline at end of file +} From 3fe8a78c84e9ab9fb258403e596620441f341432 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:19:06 +0700 Subject: [PATCH 04/13] Expose per-IED FAT connection actions safely --- Models/IoTesting/IoTestModels.cs | 238 ++++++++++++++++++++----------- 1 file changed, 152 insertions(+), 86 deletions(-) diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index 1d1437d9..091c6b17 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -1,38 +1,21 @@ +using ArIED61850Tester.Models; + using System.ComponentModel; +using System.Globalization; using System.Text.Json.Serialization; namespace ArIED61850Tester.Models.IoTesting; public enum IoTestPointState { - Pending, - WaitingForOn, - WaitingForOff, + NotStarted, + WaitingForBaseline, + WaitingForOffBaseline, + ArmedForOn, + OnCaptured, Passed, Review, - Failed, - Disabled -} - -public enum IoTestLiveBindingState -{ - NotEvaluated, - DeviceNotLoaded, - SignalNotFound, - BoundExact, - BoundNormalized, - LivePointReady -} - -public enum IoTestSessionState -{ - Idle, - Running, - Paused, - Interrupted, - Completed, - Stopped, - Faulted + Failed } public enum IoEvidenceTransition @@ -45,99 +28,179 @@ public enum IoEvidenceTransition public enum IoEvidenceVerdict { Accepted, - Rejected, - Review + Review, + Rejected } -public sealed class IoTestEvidence +public enum IoTestLiveBindingState { - public IoEvidenceTransition Transition { get; init; } - public IoEvidenceVerdict Verdict { get; init; } - public string Value { get; init; } = string.Empty; - public DateTimeOffset PcTimestamp { get; init; } - public DateTimeOffset? IedTimestamp { get; init; } - public string Quality { get; init; } = string.Empty; - public string Source { get; init; } = string.Empty; - public long SourceSequence { get; init; } - public long ConnectionGeneration { get; init; } - public string Reason { get; init; } = string.Empty; + NotEvaluated, + DeviceNotLoaded, + SignalNotFound, + BoundExact, + BoundNormalized, + LivePointReady } +public sealed record IoTestObservation( + bool? NormalizedState, + string RawValue, + DateTimeOffset CapturedAt, + DateTimeOffset? IedTimestamp, + string Quality, + string AcquisitionSource, + long Sequence, + long ConnectionGeneration); + +public sealed record IoTestTransitionEvidence( + Guid EvidenceId, + IoEvidenceTransition Transition, + bool? PreviousState, + bool ObservedState, + string RawValue, + DateTimeOffset CapturedAt, + DateTimeOffset? IedTimestamp, + string Quality, + string AcquisitionSource, + long Sequence, + long ConnectionGeneration, + IoEvidenceVerdict Verdict, + string VerdictReason); + public sealed class IoTestPointRuntime : ObservableObject { - private IoTestPointState _state = IoTestPointState.Pending; - private string _currentValue = "—"; - private string _currentQuality = "—"; - private string _currentSource = "—"; + private IoTestPointState _state = IoTestPointState.NotStarted; + private bool? _lastObservedState; + private long _lastSequence = -1; + private long _connectionGeneration = -1; + private IoTestTransitionEvidence? _onEvidence; + private IoTestTransitionEvidence? _offEvidence; + private string _statusReason = "Not started"; + private int _attempt; + private string _currentValue = "-"; + private string _currentQuality = "Unknown"; + private string _currentSource = "Not connected"; private string _currentIedTimestamp = "—"; - private string _statusReason = "Waiting to start"; - private IoTestEvidence? _onEvidence; - private IoTestEvidence? _offEvidence; public IoTestPointState State { get => _state; - set + internal set { if (Set(ref _state, value)) { - Raise(nameof(StateText)); Raise(nameof(IsComplete)); + Raise(nameof(StateText)); } } } - public string CurrentValue { get => _currentValue; set => Set(ref _currentValue, value ?? "—"); } - public string CurrentQuality { get => _currentQuality; set => Set(ref _currentQuality, value ?? "—"); } - public string CurrentSource { get => _currentSource; set => Set(ref _currentSource, value ?? "—"); } - public string CurrentIedTimestamp { get => _currentIedTimestamp; set => Set(ref _currentIedTimestamp, value ?? "—"); } - public string StatusReason { get => _statusReason; set => Set(ref _statusReason, value ?? string.Empty); } - public IoTestEvidence? OnEvidence { get => _onEvidence; set { if (Set(ref _onEvidence, value)) RaiseEvidenceProperties(); } } - public IoTestEvidence? OffEvidence { get => _offEvidence; set { if (Set(ref _offEvidence, value)) RaiseEvidenceProperties(); } } + public bool? LastObservedState { get => _lastObservedState; internal set => Set(ref _lastObservedState, value); } + public long LastSequence { get => _lastSequence; internal set => Set(ref _lastSequence, value); } + public long ConnectionGeneration { get => _connectionGeneration; internal set => Set(ref _connectionGeneration, value); } + public IoTestTransitionEvidence? OnEvidence + { + get => _onEvidence; + internal set + { + if (!Set(ref _onEvidence, value)) + return; + Raise(nameof(OnRelayTimestampText)); + Raise(nameof(OnEvidenceToolTip)); + } + } + + public IoTestTransitionEvidence? OffEvidence + { + get => _offEvidence; + internal set + { + if (!Set(ref _offEvidence, value)) + return; + Raise(nameof(OffRelayTimestampText)); + Raise(nameof(OffEvidenceToolTip)); + } + } + public string StatusReason { get => _statusReason; internal set => Set(ref _statusReason, value ?? string.Empty); } + public int Attempt { get => _attempt; internal set => Set(ref _attempt, value); } + public string CurrentValue { get => _currentValue; internal set => Set(ref _currentValue, string.IsNullOrWhiteSpace(value) ? "-" : value); } + public string CurrentQuality { get => _currentQuality; internal set => Set(ref _currentQuality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + public string CurrentSource { get => _currentSource; internal set => Set(ref _currentSource, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + + [JsonIgnore] + public string CurrentIedTimestamp + { + get => _currentIedTimestamp; + internal set => Set(ref _currentIedTimestamp, string.IsNullOrWhiteSpace(value) ? "—" : value); + } + + public bool IsComplete => State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed; + [JsonIgnore] public string StateText => State switch { - IoTestPointState.Pending => "PENDING", - IoTestPointState.WaitingForOn => "WAIT ON", - IoTestPointState.WaitingForOff => "WAIT OFF", + IoTestPointState.NotStarted => "Not started", + IoTestPointState.WaitingForBaseline => "Waiting baseline", + IoTestPointState.WaitingForOffBaseline => "Waiting OFF", + IoTestPointState.ArmedForOn => "Ready for ON", + IoTestPointState.OnCaptured => "ON captured", IoTestPointState.Passed => "PASS", IoTestPointState.Review => "REVIEW", - IoTestPointState.Failed => "FAILED", - IoTestPointState.Disabled => "DISABLED", - _ => State.ToString().ToUpperInvariant() + IoTestPointState.Failed => "FAIL", + _ => State.ToString() }; - public bool IsComplete => State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed; - public string OnRelayTimestampText => EvidenceTimestampText(OnEvidence); - public string OffRelayTimestampText => EvidenceTimestampText(OffEvidence); - public string OnEvidenceToolTip => EvidenceToolTip(OnEvidence); - public string OffEvidenceToolTip => EvidenceToolTip(OffEvidence); + [JsonIgnore] + public string OnRelayTimestampText => FormatRelayTimestamp(OnEvidence?.IedTimestamp); - private static string EvidenceTimestampText(IoTestEvidence? evidence) - => evidence?.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz") ?? "—"; + [JsonIgnore] + public string OffRelayTimestampText => FormatRelayTimestamp(OffEvidence?.IedTimestamp); - private static string EvidenceToolTip(IoTestEvidence? evidence) + [JsonIgnore] + public string OnEvidenceToolTip => BuildEvidenceToolTip(OnEvidence, "ON"); + + [JsonIgnore] + public string OffEvidenceToolTip => BuildEvidenceToolTip(OffEvidence, "OFF"); + + internal void ApplyObservation(IoTestObservation observation) + { + CurrentValue = observation.RawValue; + CurrentQuality = observation.Quality; + CurrentSource = observation.AcquisitionSource; + CurrentIedTimestamp = observation.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture) ?? "—"; + } + + private static string FormatRelayTimestamp(DateTimeOffset? value) + => value?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "—"; + + private static string BuildEvidenceToolTip(IoTestTransitionEvidence? evidence, string label) { if (evidence == null) - return "No evidence captured"; - var relay = evidence.IedTimestamp?.ToString("O") ?? "unavailable"; - return $"Relay: {relay}\nPC: {evidence.PcTimestamp:O}\nQuality: {evidence.Quality}\nSource: {evidence.Source}\nVerdict: {evidence.Verdict}\n{evidence.Reason}"; + return $"{label} transition has not been captured."; + + var relay = evidence.IedTimestamp?.ToString("O", CultureInfo.InvariantCulture) ?? "not supplied"; + var captured = evidence.CapturedAt.ToString("O", CultureInfo.InvariantCulture); + return $"Relay timestamp: {relay}\nARSAS capture: {captured}\nQuality: {evidence.Quality}\nSource: {evidence.AcquisitionSource}\n{evidence.Verdict}: {evidence.VerdictReason}"; } - private void RaiseEvidenceProperties() + internal void ResetAttempt() { - Raise(nameof(OnRelayTimestampText)); - Raise(nameof(OffRelayTimestampText)); - Raise(nameof(OnEvidenceToolTip)); - Raise(nameof(OffEvidenceToolTip)); + State = IoTestPointState.WaitingForBaseline; + LastObservedState = null; + LastSequence = -1; + ConnectionGeneration = -1; + OnEvidence = null; + OffEvidence = null; + StatusReason = "Waiting for a trustworthy baseline"; + Attempt++; } } public sealed class IoTestPointPlan : ObservableObject { - private bool _testEnabled; - private IoTestLiveBindingState _liveBindingState; - private string _liveBindingReason = string.Empty; + private bool _testEnabled = true; + private IoTestLiveBindingState _liveBindingState = IoTestLiveBindingState.NotEvaluated; + private string _liveBindingReason = "Live binding has not been evaluated"; private string _liveDeviceId = string.Empty; private string _liveSignalReference = string.Empty; @@ -145,12 +208,15 @@ public sealed class IoTestPointPlan : ObservableObject public required string IedName { get; init; } public required string IpAddress { get; init; } public required string SignalName { get; init; } - public string Description { get; init; } = string.Empty; - public string ObjectReference { get; init; } = string.Empty; - public string FunctionalConstraint { get; init; } = string.Empty; - public string ExpectedOnText { get; init; } = string.Empty; - public string ExpectedOffText { get; init; } = string.Empty; - public string SignalType { get; init; } = string.Empty; + public required string ObjectReference { get; init; } + public required string FunctionalConstraint { get; init; } + public required string ExpectedOnText { get; init; } + public required string ExpectedOffText { get; init; } + public int ExpectedOnRaw { get; init; } = 1; + public int ExpectedOffRaw { get; init; } + public string DataType { get; init; } = "SDI"; + public string SignalAddress { get; init; } = string.Empty; + public string DataSetName { get; init; } = string.Empty; public string LogicalDevice { get; init; } = string.Empty; public string LogicalNode { get; init; } = string.Empty; public string DataObject { get; init; } = string.Empty; From a811c24db9bb4c3dae9f328499ccc442e85bb9c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:19:57 +0700 Subject: [PATCH 05/13] Remove global FAT preparation lock --- IoListTestingWindow.xaml.cs | 52 +++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 22 deletions(-) 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)); From c7f25f39cbf37a003f5491dc16900312ebb2d8a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:20:51 +0700 Subject: [PATCH 06/13] Add independent Connect per IED in FAT workspace --- IoListTestingWindow.ContextUx.cs | 131 ++++++++++++++++++++++++++----- 1 file changed, 112 insertions(+), 19 deletions(-) diff --git a/IoListTestingWindow.ContextUx.cs b/IoListTestingWindow.ContextUx.cs index 1bba89d4..e1c5388d 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 engineeringWindow.PrepareIoTestIedForFatAsync( + Project, + 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(); @@ -289,6 +379,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; From d1163fc914e849f40e0b5aa78f33bf7c9caee3fb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:21:10 +0700 Subject: [PATCH 07/13] Drive FAT progress from per-IED state --- IoListTestingWindow.RealPreparationProgress.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From 8780e4d2dea804044e709105932aabac7645d873 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:22:09 +0700 Subject: [PATCH 08/13] Add selected-IED independent Connect action --- IoListTestingWindow.MultiIedConnectionUx.cs | 83 +++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 IoListTestingWindow.MultiIedConnectionUx.cs 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; + } +} From b2f3b9fdbf37aa7c9e15b99be4bb55d0cf685f4b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:22:35 +0700 Subject: [PATCH 09/13] Add P1 multi-IED FAT connection regressions --- .../IoFatMultiIedConnectionRegressionTests.cs | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs diff --git a/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs new file mode 100644 index 00000000..7923b610 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs @@ -0,0 +1,138 @@ +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 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.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}'."); + } +} From 19b53ca44bd884e173a6e45d25701ae0e9cfcb62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:27:54 +0700 Subject: [PATCH 10/13] Remove obsolete global preparation guard --- IoListTestingWindow.PrintPreview.cs | 19 ------------------- 1 file changed, 19 deletions(-) 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; From 73575a681150edaf504d31ede94e6756ff858782 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:28:23 +0700 Subject: [PATCH 11/13] Lock out stale global preparation cleanup --- tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs index 7923b610..8931220d 100644 --- a/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs @@ -73,6 +73,7 @@ public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsola 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); @@ -83,6 +84,8 @@ public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsola 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); From 63e918b1865e2c7192fdc182e17b9718d7fb7b77 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:32:51 +0700 Subject: [PATCH 12/13] Preserve continuation scope ordering with Connect-only helper --- IoListTestingWindow.ContextUx.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/IoListTestingWindow.ContextUx.cs b/IoListTestingWindow.ContextUx.cs index e1c5388d..07a16b85 100644 --- a/IoListTestingWindow.ContextUx.cs +++ b/IoListTestingWindow.ContextUx.cs @@ -203,8 +203,8 @@ private async void ConnectIed_Click(object sender, RoutedEventArgs e) RaiseSelectedIedContextProperties(); }); - var preparation = await engineeringWindow.PrepareIoTestIedForFatAsync( - Project, + var preparation = await PrepareIndependentIedConnectionAsync( + engineeringWindow, targetIed, progress, connectionScope); @@ -368,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)) From 7845dbaf5d6071654ff885d9d75aca0b3af49c28 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 13 Aug 2026 10:33:24 +0700 Subject: [PATCH 13/13] Update preview regression for per-IED progress ownership --- tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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); }