diff --git a/.github/workflows/progressive-static-bench.yml b/.github/workflows/progressive-static-bench.yml new file mode 100644 index 000000000..86441aa27 --- /dev/null +++ b/.github/workflows/progressive-static-bench.yml @@ -0,0 +1,114 @@ +name: Build Progressive Static IED Bench + +on: + push: + branches: + - fix/static-dataset-rcb-backed-acquisition + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-progressive-static: + name: Build Progressive Static portable EXE + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Resolve immutable ARIEC engine pin + shell: powershell + run: | + $lock = Get-Content .\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid ARIEC61850 integration lock.' + } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout exact ARIEC engine revision + shell: powershell + run: | + $engineRoot = Join-Path $env:RUNNER_TEMP 'ARIEC61850' + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" $engineRoot + git -C $engineRoot fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT + git -C $engineRoot checkout --quiet --detach $env:ARIEC61850_COMMIT + $actual = (git -C $engineRoot rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { + throw "Engine pin mismatch: expected $env:ARIEC61850_COMMIT, got $actual" + } + $engineProject = Join-Path $engineRoot 'src\AR.Iec61850\AR.Iec61850.csproj' + $npcapProject = Join-Path $engineRoot 'src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj' + "ENGINE_PROJECT=$engineProject" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "NPCAP_PROJECT=$npcapProject" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Apply Progressive Static bench policy + shell: powershell + run: | + .\scripts\apply-progressive-static-bench.ps1 -ProjectRoot $env:GITHUB_WORKSPACE + $runtime = Get-Content .\Services\Iec61850MonitorRuntime.cs -Raw + $selection = Get-Content .\Services\Iec61850StaticDataSetAuthoritySelection.cs -Raw + if ($runtime -notmatch 'Progressive Static acquisition ready' -or + $runtime -notmatch 'Static DataSet: MMS polling fallback' -or + $runtime -notmatch 'uncovered MX measurement' -or + $selection -match 'reportBackedDataSets.Contains') { + throw 'Progressive Static bench source verification failed.' + } + + - name: Build patched bench source + shell: powershell + run: | + dotnet restore .\ArIED61850Tester.csproj ` + -p:ArIec61850Project="$env:ENGINE_PROJECT" ` + -p:ArIec61850NpcapProject="$env:NPCAP_PROJECT" + if ($LASTEXITCODE -ne 0) { throw "restore failed: $LASTEXITCODE" } + + dotnet build .\ArIED61850Tester.csproj -c Release --no-restore ` + -p:ArIec61850Project="$env:ENGINE_PROJECT" ` + -p:ArIec61850NpcapProject="$env:NPCAP_PROJECT" + if ($LASTEXITCODE -ne 0) { throw "build failed: $LASTEXITCODE" } + + - name: Publish portable Progressive Static EXE + shell: powershell + run: | + .\scripts\publish-windows-portable.ps1 ` + -Version '1.6.33-progressive-static' ` + -Runtime 'win-x64' ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject $env:ENGINE_PROJECT ` + -NpcapProject $env:NPCAP_PROJECT + + $source = '.\dist\ARSAS-1.6.33-progressive-static-win-x64-portable.exe' + $target = '.\dist\ARSAS-1.6.33-Progressive-Static-IED-Test.exe' + if (!(Test-Path $source -PathType Leaf)) { + throw "Portable EXE was not produced: $source" + } + Move-Item $source $target -Force + $hash = (Get-FileHash $target -Algorithm SHA256).Hash.ToLowerInvariant() + $head = (git rev-parse HEAD).Trim() + @( + "ARSAS head: $head", + "ARIEC engine: $env:ARIEC61850_COMMIT", + 'Bench policy: configured RCB first; uncovered MX/measurement -> bounded MMS polling; uncovered discrete -> fail-closed; dynamic DataSet writes -> disabled.', + "SHA256: $hash" + ) | Set-Content .\dist\Progressive-Static-build-info.txt -Encoding utf8 + Write-Host "SHA256=$hash" + + - name: Upload Progressive Static IED test artifact + uses: actions/upload-artifact@v4 + with: + name: ARSAS-Progressive-Static-IED-Test + path: | + dist/ARSAS-1.6.33-Progressive-Static-IED-Test.exe + dist/Progressive-Static-build-info.txt + if-no-files-found: error + retention-days: 14 diff --git a/.release/windows.json b/.release/windows.json index 6b76e5ffe..21ece3c1b 100644 --- a/.release/windows.json +++ b/.release/windows.json @@ -1,7 +1,7 @@ { - "version": "1.6.33", + "version": "1.6.34", "channel": "stable", "platform": "windows-x64", "primaryAsset": "ARSAS-Windows-x64-Setup.exe", - "publicationRequest": 21 + "publicationRequest": 22 } diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index 56f69afae..66182f748 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -15,9 +15,9 @@ ARSAS ARSAS - IEC 61850 Engineering Workstation Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation. - 1.6.33 - 1.6.33.0 - 1.6.33.0 + 1.6.34 + 1.6.34.0 + 1.6.34.0 https://github.com/masarray/arsas https://github.com/masarray/arsas git diff --git a/Assets/RelayFascia.svg b/Assets/RelayFascia.svg new file mode 100644 index 000000000..a5302ad8b --- /dev/null +++ b/Assets/RelayFascia.svg @@ -0,0 +1,47 @@ + + + + + Layer 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 2defd62c4..f0e12e8a9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 1.6.33 - 1.6.33.0 - 1.6.33.0 - 1.6.33 + 1.6.34 + 1.6.34.0 + 1.6.34.0 + 1.6.34 diff --git a/FaultRecordWindow.HeaderSelection.cs b/FaultRecordWindow.HeaderSelection.cs index 1540a6857..efefeb702 100644 --- a/FaultRecordWindow.HeaderSelection.cs +++ b/FaultRecordWindow.HeaderSelection.cs @@ -9,9 +9,9 @@ namespace ArIED61850Tester; /// -/// Adds a tri-state select-all checkbox to the fault-record Get column. Selecting all -/// affects only rows that are currently eligible for download; clearing always clears -/// every row so stale disabled selections cannot remain hidden. +/// Adds a tri-state select-all checkbox to the fault-record Get column. A record with relay +/// files is selectable whether it is a first download or an intentional re-download. +/// FaultRecordRow.IsSelected is the single transfer-selection authority for every row. /// public partial class FaultRecordWindow { @@ -75,7 +75,7 @@ private void EnsureFaultRecordHeaderSelection() VerticalAlignment = VerticalAlignment.Center, Cursor = Cursors.Hand, Focusable = true, - ToolTip = "Check / uncheck all downloadable fault records" + ToolTip = "Check / uncheck all downloadable or re-downloadable fault records" }; AutomationProperties.SetName(headerCheckBox, "Toggle all downloadable fault records"); headerCheckBox.Click += FaultRecordHeaderSelectionCheckBox_Click; @@ -104,12 +104,7 @@ private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEvent try { foreach (var row in Records) - { - if (target) - row.IsSelected = row.CanSelectForDownload; - else - row.IsSelected = false; - } + row.IsSelected = target && row.CanSelectForDownload; } finally { @@ -117,6 +112,8 @@ private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEvent } RaiseSelectionState(); + UpdateSmartSelectionUi(); + ConfigureVisibleRecordRows(); RefreshFaultRecordHeaderSelection(); } diff --git a/FaultRecordWindow.RedownloadSelectionAuthority.cs b/FaultRecordWindow.RedownloadSelectionAuthority.cs new file mode 100644 index 000000000..47df4529b --- /dev/null +++ b/FaultRecordWindow.RedownloadSelectionAuthority.cs @@ -0,0 +1,93 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// One pointer-selection authority for the fault-record grid. Clicking either the SELECT +/// checkbox or anywhere on a transferable record row toggles FaultRecordRow.IsSelected exactly +/// once. Local Downloaded state never owns a second selection set; it only changes transfer +/// semantics from first download to staged atomic replacement. +/// +public partial class FaultRecordWindow +{ + [ModuleInitializer] + internal static void RegisterRedownloadSelectionAuthority() + { + EventManager.RegisterClassHandler( + typeof(DataGrid), + UIElement.PreviewMouseLeftButtonDownEvent, + new MouseButtonEventHandler(RedownloadSelectionAuthority_Down), + handledEventsToo: true); + } + + private static void RedownloadSelectionAuthority_Down(object sender, MouseButtonEventArgs e) + { + if (sender is not DataGrid grid || + Window.GetWindow(grid) is not FaultRecordWindow window || + !ReferenceEquals(grid, window.FaultRecordsGrid) || + window.IsBusy || + e.ChangedButton != MouseButton.Left || + !TryResolveTransferRow(e.OriginalSource as DependencyObject, out var row) || + !row.CanSelectForDownload) + { + return; + } + + row.IsSelected = !row.IsSelected; + + // Suppress the native checkbox/DataGrid toggle so one pointer action means exactly + // one selection change. Repaint after input processing; doing this synchronously in + // PreviewMouseDown lets the CheckBox template's pressed-state transition erase the + // visible tick even though the model already changed. + e.Handled = true; + window.Dispatcher.BeginInvoke( + DispatcherPriority.Input, + new Action(() => + { + window.ConfigureRecordRow(row); + window.UpdateSmartSelectionUi(); + })); + } + + private static bool TryResolveTransferRow(DependencyObject? source, out FaultRecordRow row) + { + row = null!; + var dataGridRow = FindRedownloadSelectionAncestor(source); + if (dataGridRow?.DataContext is not FaultRecordRow candidate) + return false; + + row = candidate; + return true; + } + + private static T? FindRedownloadSelectionAncestor(DependencyObject? source) + where T : DependencyObject + { + var current = source; + while (current != null) + { + if (current is T match) + return match; + + DependencyObject? parent = null; + try + { + parent = VisualTreeHelper.GetParent(current); + } + catch (InvalidOperationException) + { + } + + if (parent == null && current is FrameworkElement element) + parent = element.Parent; + current = parent; + } + + return null; + } +} diff --git a/FaultRecordWindow.RedownloadUx.cs b/FaultRecordWindow.RedownloadUx.cs index 1a386e9ec..77dfbde98 100644 --- a/FaultRecordWindow.RedownloadUx.cs +++ b/FaultRecordWindow.RedownloadUx.cs @@ -13,13 +13,14 @@ namespace ArIED61850Tester; /// -/// Adds an explicit re-download workflow without weakening the persistent local-state -/// indication. A green row remains selectable, the fresh package is downloaded into a -/// separate complete directory first, and only then replaces the known-good local copy. +/// Provides one operator selection model for both first-time downloads and re-downloads. +/// FaultRecordRow.IsSelected is the only selection authority. A downloaded row remains +/// selectable; its local state changes transfer semantics to a staged, validated, atomic +/// replacement so a known-good local COMTRADE package is never deleted before its fresh copy +/// is ready to commit. /// public partial class FaultRecordWindow { - private readonly HashSet _redownloadSelections = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _redownloadRowHandlers = new(); private Button? _smartDownloadButton; private TextBlock? _smartSelectionSummary; @@ -33,8 +34,6 @@ private void InstallRedownloadUx() _redownloadUxInstalled = true; - // Toasts are feedback for the whole workflow, not a header control. Keeping them - // centered prevents them from covering Scan fault records in the top-right corner. ToastHost.HorizontalAlignment = HorizontalAlignment.Center; ToastHost.VerticalAlignment = VerticalAlignment.Center; ToastHost.Margin = new Thickness(0); @@ -102,11 +101,6 @@ private void RedownloadUx_RecordsChanged(object? sender, NotifyCollectionChanged AttachRedownloadRow(row); } - var validIds = Records - .Select(row => row.Record.RecordId) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - _redownloadSelections.RemoveWhere(id => !validIds.Contains(id)); - Dispatcher.BeginInvoke( DispatcherPriority.Loaded, new Action(() => @@ -145,11 +139,8 @@ private void AttachRedownloadRow(FaultRecordRow row) private void DetachRedownloadRow(FaultRecordRow row) { - if (!_redownloadRowHandlers.Remove(row, out var handler)) - return; - - row.PropertyChanged -= handler; - _redownloadSelections.Remove(row.Record.RecordId); + if (_redownloadRowHandlers.Remove(row, out var handler)) + row.PropertyChanged -= handler; } private void RedownloadUx_LoadingRow(object? sender, DataGridRowEventArgs e) @@ -181,19 +172,6 @@ private void ConfigureDataGridRow(DataGridRow gridRow) if (checkBox == null) return; - checkBox.Click -= DownloadedRecordCheckBox_Click; - - if (row.LocalState == FaultRecordLocalState.Downloaded) - { - BindingOperations.ClearBinding(checkBox, ToggleButton.IsCheckedProperty); - BindingOperations.ClearBinding(checkBox, UIElement.IsEnabledProperty); - checkBox.IsEnabled = row.Record.Files.Count > 0; - checkBox.IsChecked = _redownloadSelections.Contains(row.Record.RecordId); - checkBox.ToolTip = "Already downloaded. Select to download again and overwrite the local copy."; - checkBox.Click += DownloadedRecordCheckBox_Click; - return; - } - BindingOperations.SetBinding( checkBox, ToggleButton.IsCheckedProperty, @@ -209,20 +187,9 @@ private void ConfigureDataGridRow(DataGridRow gridRow) { Mode = BindingMode.OneWay }); - checkBox.ToolTip = null; - } - - private void DownloadedRecordCheckBox_Click(object sender, RoutedEventArgs e) - { - if (sender is not CheckBox checkBox || checkBox.DataContext is not FaultRecordRow row) - return; - - if (checkBox.IsChecked == true) - _redownloadSelections.Add(row.Record.RecordId); - else - _redownloadSelections.Remove(row.Record.RecordId); - - UpdateSmartSelectionUi(); + checkBox.ToolTip = row.LocalState == FaultRecordLocalState.Downloaded + ? "Already downloaded. Select to fetch a fresh relay copy and atomically replace the existing local package." + : null; } private void ResolveSmartDownloadControls() @@ -296,9 +263,7 @@ private async void StartSmartDownload() private async Task RunSmartDownloadAsync() { var selected = Records - .Where(row => - row.Record.Files.Count > 0 && - (row.IsSelected || _redownloadSelections.Contains(row.Record.RecordId))) + .Where(row => row.IsSelected && row.CanSelectForDownload) .ToArray(); if (selected.Length == 0) @@ -339,11 +304,14 @@ private async Task RunSmartDownloadAsync() var overwriteExisting = row.LocalState == FaultRecordLocalState.Downloaded && !string.IsNullOrWhiteSpace(previousDirectory) && Directory.Exists(previousDirectory); + var stagingRoot = overwriteExisting + ? Path.Combine(Path.GetFullPath(DestinationDirectory), $".arsas-redownload-{Guid.NewGuid():N}") + : DestinationDirectory; row.Status = "Downloading"; row.Detail = string.Empty; StatusText = overwriteExisting - ? $"Downloading a fresh copy of {row.RecordName} ({index + 1}/{selected.Length})…" + ? $"Downloading a fresh copy of {row.RecordName} before overwrite ({index + 1}/{selected.Length})…" : $"Downloading {row.RecordName} ({index + 1}/{selected.Length})…"; var recordIndex = index; @@ -358,23 +326,26 @@ private async Task RunSmartDownloadAsync() StatusText = $"{row.RecordName}: {FormatBytes(item.BytesTransferred)} transferred, file {item.CompletedFiles}/{item.TotalFiles}."; }); - var result = await _client.DownloadAsync( - row.Record, - DestinationDirectory, - progress, - _operationCancellation.Token); - - if (!result.IsSuccess) - { - failedRecords++; - row.Status = "Failed"; - row.Detail = result.Message; - ProgressValue = ((index + 1d) / selected.Length) * 100d; - continue; - } - try { + if (overwriteExisting) + Directory.CreateDirectory(stagingRoot); + + var result = await _client.DownloadAsync( + row.Record, + stagingRoot, + progress, + _operationCancellation.Token); + + if (!result.IsSuccess) + { + failedRecords++; + row.Status = "Failed"; + row.Detail = result.Message; + continue; + } + + ValidateFreshRecordDirectory(row.Record, result.DestinationDirectory); var committedDirectory = CommitFreshRecordDirectory( previousDirectory, result.DestinationDirectory, @@ -387,40 +358,44 @@ private async Task RunSmartDownloadAsync() downloadedBytes += result.BytesTransferred; row.MarkDownloaded(committedDirectory); row.IsSelected = false; - _redownloadSelections.Remove(row.Record.RecordId); ConfigureRecordRow(row); } - catch (Exception ex) when ( - ex is IOException or - UnauthorizedAccessException or - ArgumentException or - InvalidOperationException) + catch (OperationCanceledException) { + throw; + } + catch (Exception ex) + { + // A single corrupt/missing COMTRADE package must not abort the remaining + // selected records. The old downloaded package is untouched until commit; + // first-time partial files remain honestly detectable as Local partial. failedRecords++; row.Status = "Failed"; - row.Detail = - "The new relay copy downloaded successfully, but replacing the previous local record failed: " + - $"{ex.GetType().Name}: {ex.Message}. The fresh copy remains at '{result.DestinationDirectory}'."; + row.Detail = $"{ex.GetType().Name}: {ex.Message}"; + } + finally + { + if (overwriteExisting) + TryRemoveDirectory(stagingRoot); + ProgressValue = ((index + 1d) / selected.Length) * 100d; } - - ProgressValue = ((index + 1d) / selected.Length) * 100d; } RefreshLocalDownloadStates(preserveFailureStatus: true); StatusText = failedRecords == 0 ? overwrittenRecords > 0 - ? $"Downloaded {completedRecords:N0} record(s); automatically overwrote {overwrittenRecords:N0} existing local record(s)." + ? $"Downloaded {completedRecords:N0} record(s); atomically replaced {overwrittenRecords:N0} existing local record(s)." : $"Downloaded {completedRecords:N0} record(s), {FormatBytes(downloadedBytes)}, to '{DestinationDirectory}'." - : $"Downloaded {completedRecords:N0} record(s); {failedRecords:N0} failed. Select a failed row to review diagnostics."; + : $"Downloaded {completedRecords:N0} record(s); {failedRecords:N0} failed. Failed rows remain selected for retry."; if (failedRecords == 0) { var toast = completedRecords == 1 ? overwrittenRecords == 1 - ? $"File downloaded and overwritten — {selected[0].RecordName}" + ? $"File downloaded and replaced safely — {selected[0].RecordName}" : $"File downloaded — {selected[0].RecordName}" : overwrittenRecords > 0 - ? $"{completedRecords:N0} downloaded; {overwrittenRecords:N0} overwritten." + ? $"{completedRecords:N0} downloaded; {overwrittenRecords:N0} replaced safely." : $"{completedRecords:N0} fault records downloaded successfully."; ShowToast(toast, ToastKind.Success); } @@ -430,17 +405,17 @@ ArgumentException or } else { - ShowToast("Download failed. Transfer diagnostics opened automatically.", ToastKind.Error); + ShowToast("Download failed. Failed rows remain selected for retry.", ToastKind.Error); } } catch (OperationCanceledException) { - StatusText = "Fault-record download cancelled. Partial temporary files were cleaned up."; + StatusText = "Fault-record download cancelled. Staged temporary files were cleaned up."; ShowToast("Download cancelled safely.", ToastKind.Information); } catch (Exception ex) { - StatusText = $"Fault-record download failed: {ex.Message}"; + StatusText = $"Fault-record download failed before record processing: {ex.Message}"; ShowToast("Download failed. Review transfer diagnostics.", ToastKind.Error); } finally @@ -453,6 +428,37 @@ ArgumentException or } } + private static void ValidateFreshRecordDirectory( + Iec61850FaultRecordSet record, + string freshDirectory) + { + if (string.IsNullOrWhiteSpace(freshDirectory) || !Directory.Exists(freshDirectory)) + throw new InvalidDataException("The relay transfer did not produce a record directory."); + + if (record.Files.Count == 0) + throw new InvalidDataException("The relay record contains no transferable files."); + + foreach (var remoteFile in record.Files) + { + var localPath = Path.Combine(freshDirectory, SanitizeLocalFileName(remoteFile.Name)); + if (!File.Exists(localPath)) + { + throw new InvalidDataException( + $"Fresh record validation failed because '{remoteFile.Name}' is missing."); + } + + if (remoteFile.SizeBytes is not > 0) + continue; + + var actualBytes = new FileInfo(localPath).Length; + if (actualBytes != remoteFile.SizeBytes.Value) + { + throw new InvalidDataException( + $"Fresh record validation failed for '{remoteFile.Name}': expected {remoteFile.SizeBytes.Value:N0} bytes, got {actualBytes:N0}."); + } + } + } + private static string CommitFreshRecordDirectory( string previousDirectory, string freshDirectory, @@ -558,11 +564,7 @@ private void UpdateSmartSelectionUi() { ResolveSmartDownloadControls(); - var normalSelected = Records.Count(row => row.IsSelected && row.Record.Files.Count > 0); - var redownloadSelected = Records.Count(row => - _redownloadSelections.Contains(row.Record.RecordId) && - row.Record.Files.Count > 0); - var selected = normalSelected + redownloadSelected; + var selected = Records.Count(row => row.IsSelected && row.CanSelectForDownload); var downloaded = Records.Count(row => row.LocalState == FaultRecordLocalState.Downloaded); if (_smartSelectionSummary != null) @@ -584,6 +586,19 @@ private void HideStartupScanToast() ToastHost.Opacity = 0; } + private static T? FindVisualAncestor(DependencyObject? child) + where T : DependencyObject + { + var current = child; + while (current != null) + { + if (current is T match) + return match; + current = VisualTreeHelper.GetParent(current); + } + return null; + } + private static IEnumerable FindVisualDescendants(DependencyObject root) where T : DependencyObject { @@ -601,4 +616,4 @@ private static IEnumerable FindVisualDescendants(DependencyObject root) yield return nested; } } -} \ No newline at end of file +} diff --git a/FaultRecordWindow.xaml.cs b/FaultRecordWindow.xaml.cs index 214b0c752..4ff0196d0 100644 --- a/FaultRecordWindow.xaml.cs +++ b/FaultRecordWindow.xaml.cs @@ -202,130 +202,13 @@ private void ChooseFolder_Click(object sender, RoutedEventArgs e) ShowToast("Download folder updated and local files rechecked.", ToastKind.Information); } - private async void Download_Click(object sender, RoutedEventArgs e) + private void Download_Click(object sender, RoutedEventArgs e) { - if (IsBusy) - return; - - var checkedRows = Records - .Where(row => row.IsSelected && row.Record.Files.Count > 0) - .ToArray(); - var selected = checkedRows - .Where(row => row.CanSelectForDownload) - .ToArray(); - var skippedRecords = checkedRows.Length - selected.Length; - - if (selected.Length == 0) - { - StatusText = skippedRecords > 0 - ? "The selected record already exists locally. Choose another record to download." - : "Select at least one available fault record."; - ShowToast( - skippedRecords > 0 ? "Already downloaded — no duplicate was created." : "Select a record first.", - ToastKind.Information); - return; - } - - if (string.IsNullOrWhiteSpace(DestinationDirectory)) - { - StatusText = "Choose a local destination directory before downloading."; - ShowToast("Choose a download folder first.", ToastKind.Information); - return; - } - - ResetOperationCancellation(); - IsBusy = true; - IsIndeterminate = false; - ProgressValue = 0; - var completedRecords = 0; - var failedRecords = 0; - long downloadedBytes = 0; - - try - { - Directory.CreateDirectory(DestinationDirectory); - await _client.ConnectAsync(_host, _port, _operationCancellation!.Token); - - for (var index = 0; index < selected.Length; index++) - { - _operationCancellation.Token.ThrowIfCancellationRequested(); - var row = selected[index]; - row.Status = "Downloading"; - row.Detail = string.Empty; - StatusText = $"Downloading {row.RecordName} ({index + 1}/{selected.Length})…"; - - var recordIndex = index; - var progress = new Progress(item => - { - var withinRecord = item.ExpectedBytes is > 0 - ? Math.Clamp(item.BytesTransferred / (double)item.ExpectedBytes.Value, 0d, 1d) - : item.TotalFiles > 0 - ? Math.Clamp(item.CompletedFiles / (double)item.TotalFiles, 0d, 1d) - : 0d; - ProgressValue = ((recordIndex + withinRecord) / selected.Length) * 100d; - StatusText = $"{row.RecordName}: {FormatBytes(item.BytesTransferred)} transferred, file {item.CompletedFiles}/{item.TotalFiles}."; - }); - - var result = await _client.DownloadAsync( - row.Record, - DestinationDirectory, - progress, - _operationCancellation.Token); - - if (result.IsSuccess) - { - completedRecords++; - downloadedBytes += result.BytesTransferred; - row.MarkDownloaded(result.DestinationDirectory); - } - else - { - failedRecords++; - row.Status = "Failed"; - row.Detail = result.Message; - } - - ProgressValue = ((index + 1d) / selected.Length) * 100d; - } - - RefreshLocalDownloadStates(preserveFailureStatus: true); - StatusText = failedRecords == 0 - ? $"Downloaded {completedRecords:N0} record(s), {FormatBytes(downloadedBytes)}, to '{DestinationDirectory}'." - : $"Downloaded {completedRecords:N0} record(s); {failedRecords:N0} failed. Select a failed row to review diagnostics."; - - if (failedRecords == 0) - { - ShowToast( - completedRecords == 1 - ? $"File downloaded — {selected[0].RecordName}" - : $"{completedRecords:N0} fault records downloaded successfully.", - ToastKind.Success); - } - else if (completedRecords > 0) - { - ShowToast($"{completedRecords:N0} downloaded, {failedRecords:N0} failed.", ToastKind.Warning); - } - else - { - ShowToast("Download failed. Transfer diagnostics opened automatically.", ToastKind.Error); - } - } - catch (OperationCanceledException) - { - StatusText = "Fault-record download cancelled. Partial temporary files were cleaned up."; - ShowToast("Download cancelled safely.", ToastKind.Information); - } - catch (Exception ex) - { - StatusText = $"Fault-record download failed: {ex.Message}"; - ShowToast("Download failed. Review transfer diagnostics.", ToastKind.Error); - } - finally - { - IsIndeterminate = false; - IsBusy = false; - RaiseSelectionState(); - } + // P1 has one transfer path for both first-time downloads and re-downloads. The smart + // path stages an existing record beside the destination and commits only after the + // fresh package is complete, so programmatic Click invocation is as safe as pointer/ + // keyboard activation intercepted by FaultRecordWindow.RedownloadUx.cs. + StartSmartDownload(); } private void Cancel_Click(object sender, RoutedEventArgs e) @@ -592,7 +475,9 @@ public FaultRecordRow(Iec61850FaultRecordSet record) ? "PACKAGE" : file.Extension.TrimStart('.').ToUpperInvariant())); - public bool CanSelectForDownload => Record.Files.Count > 0 && LocalState != FaultRecordLocalState.Downloaded; + // P1: one transfer-selection authority. A complete local copy remains selectable so the + // operator can explicitly re-download it; local state describes storage, not permission. + public bool CanSelectForDownload => Record.Files.Count > 0; public bool IsSelected { @@ -656,10 +541,7 @@ private set return; _localState = value; - if (_localState == FaultRecordLocalState.Downloaded) - _isSelected = false; Raise(); - Raise(nameof(IsSelected)); Raise(nameof(CanSelectForDownload)); } } @@ -670,8 +552,8 @@ public void MarkDownloaded(string localDirectory) LocalState = FaultRecordLocalState.Downloaded; Status = "Downloaded"; Detail = string.IsNullOrWhiteSpace(LocalDirectory) - ? "The complete record exists in the selected local folder." - : $"Downloaded to '{LocalDirectory}'."; + ? "The complete record exists in the selected local folder and may be selected again for staged re-download." + : $"Downloaded to '{LocalDirectory}'. Select again to replace it with a fresh relay copy."; } public void ApplyLocalState( @@ -686,8 +568,8 @@ public void ApplyLocalState( { Status = "Downloaded"; Detail = string.IsNullOrWhiteSpace(LocalDirectory) - ? "The complete record exists in the selected local folder." - : $"Already downloaded to '{LocalDirectory}'."; + ? "The complete record exists in the selected local folder and may be re-downloaded." + : $"Already downloaded to '{LocalDirectory}'. Select the row to replace it safely with a fresh relay copy."; return; } diff --git a/IoListTestingWindow.ActiveFatView.cs b/IoListTestingWindow.ActiveFatView.cs new file mode 100644 index 000000000..8790b01f7 --- /dev/null +++ b/IoListTestingWindow.ActiveFatView.cs @@ -0,0 +1,126 @@ +using System.Collections; +using System.ComponentModel; +using System.Windows; +using System.Windows.Data; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Owns the active FAT grid projection. TestPoints remains the retained project/evidence +/// collection, while the visible grid contains only points whose FAT disposition is Included. +/// Remove/restore changes workspace membership only. TestEnabled is an operator-owned flag: +/// no background projection, refresh, reconnect or FAT lifecycle is allowed to toggle it. +/// +public partial class IoListTestingWindow +{ + private static readonly bool ActiveFatViewClassHandlerRegistered = RegisterActiveFatViewClassHandler(); + private readonly HashSet _activeFatViewPoints = new(); + private ListCollectionView? _activeFatView; + private IoTestIedPlan? _activeFatViewIed; + private bool _activeFatViewInstalled; + + private static bool RegisterActiveFatViewClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ActiveFatView_Loaded)); + return true; + } + + private static void ActiveFatView_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._activeFatViewInstalled) + return; + + window._activeFatViewInstalled = true; + window.PropertyChanged += window.ActiveFatView_WindowPropertyChanged; + window.Closed += window.ActiveFatView_Closed; + window.Dispatcher.BeginInvoke( + new Action(window.RefreshActiveFatView), + DispatcherPriority.ContextIdle); + } + + private void ActiveFatView_WindowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedIed)) + return; + + Dispatcher.BeginInvoke(new Action(RefreshActiveFatView), DispatcherPriority.DataBind); + } + + private void RefreshActiveFatView() + { + if (_fatSignalsGrid == null) + { + if (IsLoaded) + Dispatcher.BeginInvoke(new Action(RefreshActiveFatView), DispatcherPriority.ContextIdle); + return; + } + + DetachActiveFatViewPoints(); + _activeFatViewIed = SelectedIed; + + if (_activeFatViewIed == null) + { + _activeFatView = null; + _fatSignalsGrid.ItemsSource = null; + return; + } + + foreach (var point in _activeFatViewIed.TestPoints) + { + point.PropertyChanged += ActiveFatView_PointPropertyChanged; + _activeFatViewPoints.Add(point); + } + + _activeFatView = new ListCollectionView((IList)_activeFatViewIed.TestPoints) + { + Filter = item => item is IoTestPointPlan point && point.IsIncludedInFat + }; + _fatSignalsGrid.ItemsSource = _activeFatView; + } + + private void ActiveFatView_PointPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not IoTestPointPlan point || + e.PropertyName is not (nameof(IoTestPointPlan.FatDisposition) or nameof(IoTestPointPlan.IsIncludedInFat))) + { + return; + } + + void ApplyMembershipChange() + { + if (!_activeFatViewPoints.Contains(point)) + return; + + // Only the active projection changes here. TestEnabled must never be changed as a + // side effect: checked/unchecked state belongs exclusively to explicit operator input. + _activeFatView?.Refresh(); + } + + if (Dispatcher.CheckAccess()) + ApplyMembershipChange(); + else + Dispatcher.BeginInvoke(new Action(ApplyMembershipChange), DispatcherPriority.DataBind); + } + + private void DetachActiveFatViewPoints() + { + foreach (var point in _activeFatViewPoints) + point.PropertyChanged -= ActiveFatView_PointPropertyChanged; + _activeFatViewPoints.Clear(); + } + + private void ActiveFatView_Closed(object? sender, EventArgs e) + { + PropertyChanged -= ActiveFatView_WindowPropertyChanged; + Closed -= ActiveFatView_Closed; + DetachActiveFatViewPoints(); + _activeFatView = null; + _activeFatViewIed = null; + _activeFatViewInstalled = false; + } +} diff --git a/IoListTestingWindow.ClockSyncUx.cs b/IoListTestingWindow.ClockSyncUx.cs index 2ca060789..7b265d1a2 100644 --- a/IoListTestingWindow.ClockSyncUx.cs +++ b/IoListTestingWindow.ClockSyncUx.cs @@ -40,9 +40,9 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) Margin = new Thickness(0, 0, 8, 0), Padding = new Thickness(2, 0, 2, 0), FontSize = 11.2, - FontWeight = FontWeights.SemiBold, + FontWeight = FontWeights.Medium, Foreground = TryFindResource("Ink") as Brush ?? Brushes.DimGray, - Text = "Global SNTP", + Text = "SNTP · —", ToolTip = "Global SNTP is controlled from the ARSAS header. It is not owned by FAT and continues running when the FAT window closes while the global server toggle remains enabled." }; @@ -54,7 +54,7 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) FontSize = 10.4, FontWeight = FontWeights.Medium, Foreground = TryFindResource("MutedInk") as Brush ?? Brushes.SlateGray, - Text = "SNTP: waiting" + Text = "Clock · waiting" }; _clockSyncGlobalStatusText = globalStatus; @@ -104,25 +104,22 @@ private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot) SntpClockTransportMode.UdpSocket => "UDP", _ => "—" }; - var localAddress = snapshot.Binding?.LocalAddress.ToString(); - _clockSyncGlobalStatusText.Text = !enabled - ? "Global SNTP · Off" - : snapshot.State == SntpClockServiceState.Serving - ? $"Global SNTP · {localAddress ?? "Active"}" - : "Global SNTP · Enabled"; + _clockSyncGlobalStatusText.Text = enabled + ? "SNTP · ON" + : "SNTP · OFF"; _clockSyncEvidenceText.Text = !enabled - ? "SNTP: off" + ? "Clock · idle" : snapshot.State switch { SntpClockServiceState.Serving => - $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven", - SntpClockServiceState.Starting => "SNTP: starting…", - SntpClockServiceState.Stopped => "SNTP: waiting for connected IED", - SntpClockServiceState.PortUnavailable => "SNTP: unavailable", - SntpClockServiceState.Faulted => "SNTP: fault", - _ => $"SNTP: {snapshot.State}" + $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Rep {snapshot.ReplyCount}", + SntpClockServiceState.Starting => "Clock · starting…", + SntpClockServiceState.Stopped => "Clock · waiting", + SntpClockServiceState.PortUnavailable => "Clock · unavailable", + SntpClockServiceState.Faulted => "Clock · fault", + _ => $"Clock · {snapshot.State}" }; var toolTip = BuildClockSyncEvidenceToolTip(snapshot, transport, enabled); @@ -164,4 +161,4 @@ private void ClockSyncUx_Closed(object? sender, EventArgs e) _clockSyncSnapshotOwner.ClockSyncSnapshotChanged -= ClockSyncSnapshotChanged; _clockSyncSnapshotOwner = null; } -} +} \ No newline at end of file diff --git a/IoListTestingWindow.ColumnSizing.cs b/IoListTestingWindow.ColumnSizing.cs new file mode 100644 index 000000000..afaa5f6c0 --- /dev/null +++ b/IoListTestingWindow.ColumnSizing.cs @@ -0,0 +1,130 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Keeps the FAT IEC reference column operator-resizable and preserves the operator's most +/// recent width across FAT-window reopen in the current ARSAS session. No project/evidence +/// schema is mutated for a purely visual preference. +/// +public partial class IoListTestingWindow +{ + private const double DefaultFatIecReferenceWidth = 360d; + private const double MinimumFatIecReferenceWidth = 250d; + private const double MaximumFatIecReferenceWidth = 4096d; + private static double _sessionFatIecReferenceWidth = DefaultFatIecReferenceWidth; + + private DataGridColumn? _trackedFatIecReferenceColumn; + private bool _fatColumnWidthPersistenceInstalled; + + [ModuleInitializer] + internal static void RegisterFatColumnSizing() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(FatColumnSizing_Loaded)); + } + + private static void FatColumnSizing_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window) + return; + + // FAT V2 rebuilds the runtime columns from ContentRendered. Apply this operator + // sizing contract after that rebuild rather than relying on the original XAML + // column instances, which would be replaced a moment later. + window.Dispatcher.BeginInvoke( + new Action(window.ApplyOperatorFatColumnSizing), + DispatcherPriority.ApplicationIdle); + } + + private void ApplyOperatorFatColumnSizing() + { + _fatSignalsGrid ??= FindVisualDescendant(this); + if (_fatSignalsGrid == null) + return; + + _fatSignalsGrid.CanUserResizeColumns = true; + var referenceColumn = _fatSignalsGrid.Columns.FirstOrDefault(column => + string.Equals(column.Header?.ToString(), "IEC REFERENCE", StringComparison.OrdinalIgnoreCase)); + if (referenceColumn == null) + return; + + referenceColumn.MinWidth = Math.Max(referenceColumn.MinWidth, MinimumFatIecReferenceWidth); + referenceColumn.MaxWidth = MaximumFatIecReferenceWidth; + referenceColumn.Width = new DataGridLength(ClampFatIecReferenceWidth(_sessionFatIecReferenceWidth)); + TrackFatIecReferenceColumn(referenceColumn); + } + + private void TrackFatIecReferenceColumn(DataGridColumn column) + { + if (ReferenceEquals(_trackedFatIecReferenceColumn, column)) + return; + + if (_trackedFatIecReferenceColumn != null) + { + DependencyPropertyDescriptor + .FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn)) + ?.RemoveValueChanged(_trackedFatIecReferenceColumn, FatIecReferenceColumn_WidthChanged); + } + + _trackedFatIecReferenceColumn = column; + DependencyPropertyDescriptor + .FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn)) + ?.AddValueChanged(column, FatIecReferenceColumn_WidthChanged); + + if (_fatColumnWidthPersistenceInstalled) + return; + + _fatColumnWidthPersistenceInstalled = true; + Closed += FatColumnSizing_Closed; + } + + private void FatIecReferenceColumn_WidthChanged(object? sender, EventArgs e) + { + if (sender is not DataGridColumn column) + return; + + // ActualWidth reflects star/auto recalculation too. Persist only an explicit pixel + // width so layout passes cannot silently overwrite the operator's chosen width. + if (!column.Width.IsAbsolute || double.IsNaN(column.Width.Value) || double.IsInfinity(column.Width.Value)) + return; + + _sessionFatIecReferenceWidth = ClampFatIecReferenceWidth(column.Width.Value); + } + + private void FatColumnSizing_Closed(object? sender, EventArgs e) + { + Closed -= FatColumnSizing_Closed; + _fatColumnWidthPersistenceInstalled = false; + + if (_trackedFatIecReferenceColumn != null) + { + DependencyPropertyDescriptor + .FromProperty(DataGridColumn.WidthProperty, typeof(DataGridColumn)) + ?.RemoveValueChanged(_trackedFatIecReferenceColumn, FatIecReferenceColumn_WidthChanged); + _trackedFatIecReferenceColumn = null; + } + } + + internal static double ClampFatIecReferenceWidthForTest(double width) + => ClampFatIecReferenceWidth(width); + + internal static void SetFatIecReferenceSessionWidthForTest(double width) + => _sessionFatIecReferenceWidth = ClampFatIecReferenceWidth(width); + + internal static double GetFatIecReferenceSessionWidthForTest() + => _sessionFatIecReferenceWidth; + + private static double ClampFatIecReferenceWidth(double width) + { + if (double.IsNaN(width) || double.IsInfinity(width)) + return DefaultFatIecReferenceWidth; + return Math.Clamp(width, MinimumFatIecReferenceWidth, MaximumFatIecReferenceWidth); + } +} diff --git a/IoListTestingWindow.CommandPanel.cs b/IoListTestingWindow.CommandPanel.cs new file mode 100644 index 000000000..d08372f69 --- /dev/null +++ b/IoListTestingWindow.CommandPanel.cs @@ -0,0 +1,705 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private sealed class FatCommandRowView + { + public required Border Container { get; init; } + public required Grid Grid { get; init; } + public required FrameworkElement Actions { get; set; } + } + + private sealed class FatCommandTextConverter : IValueConverter + { + public static FatCommandTextConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = value?.ToString()?.Trim() ?? string.Empty; + return string.IsNullOrWhiteSpace(text) ? "—" : text; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } + + private sealed class FatCommandModelConverter : IValueConverter + { + public static FatCommandModelConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => FatCommandModelText(value?.ToString()); + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } + + private sealed class FatCommandCanOperateConverter : IMultiValueConverter + { + public static FatCommandCanOperateConverter Instance { get; } = new(); + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var supportsOperate = values.ElementAtOrDefault(0) is true; + var isBusy = values.ElementAtOrDefault(1) is true; + return supportsOperate && !isBusy; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => targetTypes.Select(_ => Binding.DoNothing).ToArray(); + } + + private Border? _fatCommandPanelShell; + private StackPanel? _fatCommandRows; + private TextBlock? _fatCommandSummary; + private FrameworkElement? _fatCommandEmptyState; + private Iec61850MonitorDevice? _fatCommandDevice; + private readonly HashSet _fatCommandSubscribedSignals = new(); + private readonly Dictionary _fatCommandRowViews = new(); + private bool _fatCommandPanelLifecycleInstalled; + + // Register at class level rather than overriding OnInitialized. IoListTestingWindow + // already owns an initialization override in another partial; FAT command UI must be + // additive and must not compete with the existing workspace lifecycle. + private static readonly bool FatCommandPanelClassHandlerRegistered = RegisterFatCommandPanelClassHandler(); + + private static bool RegisterFatCommandPanelClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(FatCommandPanelClassLoaded)); + return true; + } + + private static void FatCommandPanelClassLoaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._fatCommandPanelLifecycleInstalled) + return; + + window._fatCommandPanelLifecycleInstalled = true; + window.PropertyChanged += window.FatCommandPanelWindow_PropertyChanged; + window.Closed += window.FatCommandPanelWindow_Closed; + window.Dispatcher.BeginInvoke(new Action(async () => + { + window.InstallFatCommandPanel(); + await window.RefreshFatCommandPanelAsync(); + }), DispatcherPriority.ContextIdle); + } + + private void FatCommandPanelWindow_Closed(object? sender, EventArgs e) + { + PropertyChanged -= FatCommandPanelWindow_PropertyChanged; + Closed -= FatCommandPanelWindow_Closed; + DetachFatCommandDevice(); + } + + private void FatCommandPanelWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedIed)) + return; + + Dispatcher.BeginInvoke(new Action(async () => await RefreshFatCommandPanelAsync()), DispatcherPriority.Background); + } + + private void InstallFatCommandPanel() + { + if (_fatCommandPanelShell != null) + return; + + var fatGrid = FindFatCommandVisualChildren(this) + .FirstOrDefault(grid => + BindingOperations.GetBinding(grid, ItemsControl.ItemsSourceProperty)?.Path?.Path == "SelectedIed.TestPoints") + ?? FindFatCommandVisualChildren(this).FirstOrDefault(); + if (fatGrid?.Parent is not Grid hostGrid) + return; + + // Keep the FAT evidence table as the flexible row. Controls get their own compact, + // independently scrolling surface below it so a large I/O list remains usable. + hostGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) }); + hostGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var header = new Grid(); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var heading = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + heading.Children.Add(new TextBlock + { + Text = "IED COMMAND PANEL", + FontSize = 10.5, + FontWeight = FontWeights.Bold, + Foreground = FatCommandBrush("#2F6FD6") + }); + _fatCommandSummary = new TextBlock + { + Text = "Select a connected IED to load SCL/DataSet control objects.", + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.5, + Foreground = FatCommandBrush("#697A90") + }; + heading.Children.Add(_fatCommandSummary); + header.Children.Add(heading); + + var refresh = FatCommandButton("Refresh values", "SoftButton"); + refresh.Padding = new Thickness(10, 6, 10, 6); + refresh.Click += async (_, _) => await RefreshFatCommandPanelAsync(); + Grid.SetColumn(refresh, 1); + header.Children.Add(refresh); + + _fatCommandRows = new StackPanel(); + var scroller = new ScrollViewer + { + Margin = new Thickness(0, 9, 0, 0), + MaxHeight = 174, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = _fatCommandRows + }; + + var content = new StackPanel(); + content.Children.Add(header); + content.Children.Add(scroller); + + _fatCommandPanelShell = new Border + { + Background = FatCommandBrush("#F7FAFF"), + BorderBrush = FatCommandBrush("#D7E2F1"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(13), + Padding = new Thickness(11, 9, 11, 9), + MaxHeight = 238, + Child = content + }; + + Grid.SetRow(_fatCommandPanelShell, hostGrid.RowDefinitions.Count - 1); + hostGrid.Children.Add(_fatCommandPanelShell); + } + + private async Task RefreshFatCommandPanelAsync() + { + if (_fatCommandRows == null || _fatCommandSummary == null) + return; + + if (Owner is not MainWindow engineeringWindow) + { + DetachFatCommandDevice(); + _fatCommandSummary.Text = "Engineering owner unavailable; control is disabled fail-closed."; + SynchronizeFatCommandRows(); + return; + } + + var device = engineeringWindow.ResolveIoFatCommandDevice(SelectedIed); + AttachFatCommandDevice(device); + SynchronizeFatCommandRows(); + if (device == null) + { + _fatCommandSummary.Text = "No shared Engineering IED is bound to the selected FAT device."; + return; + } + + if (!device.IsConnected) + { + _fatCommandSummary.Text = $"{device.Name} is not connected; command actions remain disabled."; + return; + } + + _fatCommandSummary.Text = $"{device.Name} · validating live ctlModel and shared process values…"; + try + { + await engineeringWindow.RefreshIoFatCommandValuesAsync(device); + AttachFatCommandDevice(device); + SynchronizeFatCommandRows(); + } + catch (OperationCanceledException) + { + _fatCommandSummary.Text = $"{device.Name} · command refresh cancelled."; + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentException) + { + _fatCommandSummary.Text = $"{device.Name} · command refresh unavailable: {ex.Message}"; + } + } + + private bool AttachFatCommandDevice(Iec61850MonitorDevice? device) + { + if (ReferenceEquals(_fatCommandDevice, device)) + return false; + + DetachFatCommandDevice(); + _fatCommandDevice = device; + if (_fatCommandDevice != null) + _fatCommandDevice.CommandSignals.CollectionChanged += FatCommandSignals_CollectionChanged; + return true; + } + + private void DetachFatCommandDevice() + { + if (_fatCommandDevice != null) + _fatCommandDevice.CommandSignals.CollectionChanged -= FatCommandSignals_CollectionChanged; + + foreach (var signal in _fatCommandSubscribedSignals) + signal.PropertyChanged -= FatCommandSignal_PropertyChanged; + _fatCommandSubscribedSignals.Clear(); + _fatCommandDevice = null; + } + + private void FatCommandSignals_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + => Dispatcher.BeginInvoke(new Action(SynchronizeFatCommandRows), DispatcherPriority.Background); + + private void FatCommandSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not SignalDefinition signal) + return; + + // LIVE VALUE, result text and busy/enabled state are WPF bindings on the existing + // row instance. Only a semantic action-layout transition needs to replace the small + // action cell; the row and the rest of the panel remain untouched. + if (e.PropertyName is nameof(SignalDefinition.ControlConfirmationPending) + or nameof(SignalDefinition.ControlCdc) + or nameof(SignalDefinition.ControlModelText) + or nameof(SignalDefinition.ControlModelResolved)) + { + Dispatcher.BeginInvoke( + new Action(() => RefreshFatCommandActions(signal)), + DispatcherPriority.Background); + } + } + + private void SynchronizeFatCommandRows() + { + if (_fatCommandRows == null || _fatCommandSummary == null) + return; + + var device = _fatCommandDevice; + var commands = device?.CommandSignals.ToArray() ?? Array.Empty(); + var commandSet = commands.ToHashSet(); + + foreach (var stale in _fatCommandRowViews.Keys.Where(signal => !commandSet.Contains(signal)).ToArray()) + RemoveFatCommandRow(stale); + + if (commands.Length == 0) + { + _fatCommandSummary.Text = device == null + ? "No FAT command device selected." + : $"{device.Name} · no operable control is proven by live ctlModel. Status-only controls remain read-only."; + + EnsureFatCommandEmptyState(device == null + ? "No FAT command device selected." + : "No command action is available. Controls appear only after live ctlModel proves Direct/SBO operation; StatusOnly and unsupported generic types stay fail-closed."); + return; + } + + RemoveFatCommandEmptyState(); + _fatCommandSummary.Text = $"{device!.Name} · {commands.Length} operable DataSet control(s) · shared Engineering command backend"; + + for (var index = 0; index < commands.Length; index++) + { + var signal = commands[index]; + if (!_fatCommandRowViews.TryGetValue(signal, out var view)) + { + signal.PropertyChanged += FatCommandSignal_PropertyChanged; + _fatCommandSubscribedSignals.Add(signal); + view = BuildFatCommandRow(signal); + _fatCommandRowViews[signal] = view; + _fatCommandRows.Children.Insert(Math.Min(index, _fatCommandRows.Children.Count), view.Container); + } + + var currentIndex = _fatCommandRows.Children.IndexOf(view.Container); + if (currentIndex >= 0 && currentIndex != index) + { + _fatCommandRows.Children.RemoveAt(currentIndex); + _fatCommandRows.Children.Insert(Math.Min(index, _fatCommandRows.Children.Count), view.Container); + } + } + } + + private void EnsureFatCommandEmptyState(string text) + { + if (_fatCommandRows == null) + return; + + if (_fatCommandEmptyState is TextBlock existing) + { + existing.Text = text; + if (!_fatCommandRows.Children.Contains(existing)) + _fatCommandRows.Children.Add(existing); + return; + } + + _fatCommandEmptyState = FatCommandEmptyText(text); + _fatCommandRows.Children.Add(_fatCommandEmptyState); + } + + private void RemoveFatCommandEmptyState() + { + if (_fatCommandRows == null || _fatCommandEmptyState == null) + return; + _fatCommandRows.Children.Remove(_fatCommandEmptyState); + _fatCommandEmptyState = null; + } + + private void RemoveFatCommandRow(SignalDefinition signal) + { + if (_fatCommandSubscribedSignals.Remove(signal)) + signal.PropertyChanged -= FatCommandSignal_PropertyChanged; + + if (!_fatCommandRowViews.Remove(signal, out var view) || _fatCommandRows == null) + return; + _fatCommandRows.Children.Remove(view.Container); + } + + private FatCommandRowView BuildFatCommandRow(SignalDefinition signal) + { + var row = new Grid { MinWidth = 960, DataContext = signal }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(2.25, GridUnitType.Star), MinWidth = 210 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.8, GridUnitType.Star), MinWidth = 82 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.72, GridUnitType.Star), MinWidth = 70 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.45, GridUnitType.Star), MinWidth = 145 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.1, GridUnitType.Star), MinWidth = 150 }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.55, GridUnitType.Star), MinWidth = 190 }); + + var reference = FatCommandText(signal.ObjectReference, 11.0, FontWeights.SemiBold); + reference.FontFamily = new FontFamily("Cascadia Mono, Consolas"); + reference.ToolTip = signal.ObjectReference; + AddFatCommandCell(row, reference, 0); + + var current = FatCommandText("—", 11.0, FontWeights.SemiBold); + current.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlCurrentValue)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandTextConverter.Instance + }); + current.SetBinding(FrameworkElement.ToolTipProperty, new Binding(nameof(SignalDefinition.ControlLastResult)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + AddFatCommandCell(row, current, 1); + + var cdc = FatCommandText("—", 10.8, FontWeights.SemiBold); + cdc.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlCdc)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandTextConverter.Instance + }); + AddFatCommandCell(row, cdc, 2); + + var model = FatCommandText("Reading…", 10.5, FontWeights.SemiBold); + model.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlModelText)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandModelConverter.Instance + }); + model.SetBinding(FrameworkElement.ToolTipProperty, new Binding(nameof(SignalDefinition.ControlModelText)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + AddFatCommandCell(row, model, 3); + AddFatCommandCell(row, BuildFatCommandChecks(signal), 4); + + var actions = BuildFatCommandActions(signal); + AddFatCommandCell(row, actions, 5); + + var container = new Border + { + Background = Brushes.White, + BorderBrush = FatCommandBrush("#E2E8F1"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(9), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(0, 0, 0, 6), + Child = row + }; + + return new FatCommandRowView + { + Container = container, + Grid = row, + Actions = actions + }; + } + + private void RefreshFatCommandActions(SignalDefinition signal) + { + if (!_fatCommandRowViews.TryGetValue(signal, out var view)) + return; + + var replacement = BuildFatCommandActions(signal); + view.Grid.Children.Remove(view.Actions); + AddFatCommandCell(view.Grid, replacement, 5); + view.Actions = replacement; + } + + private FrameworkElement BuildFatCommandChecks(SignalDefinition signal) + { + var panel = new WrapPanel { VerticalAlignment = VerticalAlignment.Center }; + panel.Children.Add(FatCommandCheck("Interlock", signal, nameof(SignalDefinition.ControlInterlockCheck))); + panel.Children.Add(FatCommandCheck("Sync", signal, nameof(SignalDefinition.ControlSynchroCheck))); + panel.Children.Add(FatCommandCheck("Test", signal, nameof(SignalDefinition.ControlTestMode))); + return panel; + } + + private static CheckBox FatCommandCheck(string text, SignalDefinition signal, string propertyName) + { + var check = new CheckBox + { + Content = text, + DataContext = signal, + Margin = new Thickness(0, 0, 7, 0), + FontSize = 9.6, + VerticalAlignment = VerticalAlignment.Center + }; + check.SetBinding(ToggleButton.IsCheckedProperty, new Binding(propertyName) + { + Source = signal, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + }); + return check; + } + + private FrameworkElement BuildFatCommandActions(SignalDefinition signal) + { + var panel = new WrapPanel { VerticalAlignment = VerticalAlignment.Center }; + + if (signal.IsPositionControl) + { + if (signal.ControlConfirmationPending) + { + var confirm = FatCommandButton("Confirm", "PrimaryButton"); + BindFatCommandEnabled(confirm, signal); + confirm.Click += async (_, _) => await ConfirmFatPositionControlAsync(signal); + panel.Children.Add(confirm); + + var cancel = FatCommandButton("Cancel", "SoftButton"); + cancel.Margin = new Thickness(6, 0, 0, 0); + cancel.Click += (_, _) => signal.ClearControlConfirmation(); + panel.Children.Add(cancel); + } + else + { + var open = FatCommandButton("Open", "CommandOpenButton"); + BindFatCommandEnabled(open, signal); + open.Click += (_, _) => StageFatPositionControl(signal, "Open [01]", "Open"); + panel.Children.Add(open); + + var close = FatCommandButton("Close", "CommandCloseButton"); + close.Margin = new Thickness(6, 0, 0, 0); + BindFatCommandEnabled(close, signal); + close.Click += (_, _) => StageFatPositionControl(signal, "Closed [10]", "Close"); + panel.Children.Add(close); + } + return panel; + } + + if (signal.IsRaiseOnlyControl) + { + panel.Children.Add(FatQuickCommandButton(signal, "Raise", "Raise", "PrimaryButton")); + return panel; + } + if (signal.IsLowerOnlyControl) + { + panel.Children.Add(FatQuickCommandButton(signal, "Lower", "Lower", "SoftButton")); + return panel; + } + if (signal.IsRaiseLowerControl) + { + panel.Children.Add(FatQuickCommandButton(signal, "Raise", "Raise", "PrimaryButton")); + var lower = FatQuickCommandButton(signal, "Lower", "Lower", "SoftButton"); + lower.Margin = new Thickness(6, 0, 0, 0); + panel.Children.Add(lower); + return panel; + } + if (signal.IsBooleanControl) + { + panel.Children.Add(FatQuickCommandButton(signal, "True", "True", "CommandCloseButton")); + var off = FatQuickCommandButton(signal, "False", "False", "CommandOpenButton"); + off.Margin = new Thickness(6, 0, 0, 0); + panel.Children.Add(off); + return panel; + } + if (signal.IsSetPointControl) + { + var target = new TextBox + { + Width = 88, + Height = 29, + Padding = new Thickness(6, 3, 6, 3), + Margin = new Thickness(0, 0, 6, 0), + ToolTip = "Target value" + }; + target.SetBinding(TextBox.TextProperty, new Binding(nameof(SignalDefinition.ControlSetPointText)) + { + Source = signal, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + }); + panel.Children.Add(target); + + var set = FatCommandButton("Set", "PrimaryButton"); + BindFatCommandEnabled(set, signal); + set.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, signal.ControlSetPointText, "Set"); + panel.Children.Add(set); + return panel; + } + + panel.Children.Add(FatCommandEmptyText("No safe quick action")); + return panel; + } + + private Button FatQuickCommandButton(SignalDefinition signal, string label, string requestedValue, string styleKey) + { + var button = FatCommandButton(label, styleKey); + BindFatCommandEnabled(button, signal); + button.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, requestedValue, label); + return button; + } + + private static void BindFatCommandEnabled(Button button, SignalDefinition signal) + { + var binding = new MultiBinding + { + Converter = FatCommandCanOperateConverter.Instance, + Mode = BindingMode.OneWay + }; + binding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlSupportsOperate)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + binding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlIsBusy)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + BindingOperations.SetBinding(button, UIElement.IsEnabledProperty, binding); + } + + private void StageFatPositionControl(SignalDefinition signal, string requestedValue, string actionLabel) + { + if (!signal.TryStageControlConfirmation(requestedValue, actionLabel, out var rejectionReason)) + { + signal.ControlLastResult = $"Command rejected: {rejectionReason}."; + return; + } + + RefreshFatCommandActions(signal); + } + + private async Task ConfirmFatPositionControlAsync(SignalDefinition signal) + { + if (Owner is not MainWindow engineeringWindow) + return; + if (!signal.TryClaimControlConfirmation(out var claim, out var rejectionReason) || claim == null) + { + signal.ControlLastResult = $"Command rejected: {rejectionReason}."; + return; + } + + RefreshFatCommandActions(signal); + await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); + RefreshFatCommandActions(signal); + } + + private async Task ExecuteFatQuickControlAsync(SignalDefinition signal, string requestedValue, string actionLabel) + { + if (Owner is not MainWindow engineeringWindow) + return; + if (!signal.TryBeginDirectControlCommand(requestedValue, actionLabel, out var claim, out var rejectionReason) || claim == null) + { + signal.ControlLastResult = $"Command rejected: {rejectionReason}."; + return; + } + + await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); + } + + private Button FatCommandButton(string text, string styleKey) + { + var button = new Button + { + Content = text, + MinHeight = 29, + MinWidth = 58, + Padding = new Thickness(10, 5, 10, 5), + FontSize = 10.2, + VerticalAlignment = VerticalAlignment.Center + }; + if (TryFindResource(styleKey) is Style style) + button.Style = style; + return button; + } + + private static void AddFatCommandCell(Grid row, FrameworkElement child, int column) + { + child.Margin = column == 0 ? new Thickness(0) : new Thickness(8, 0, 0, 0); + child.VerticalAlignment = VerticalAlignment.Center; + Grid.SetColumn(child, column); + row.Children.Add(child); + } + + private static TextBlock FatCommandText(string text, double size, FontWeight weight) + => new() + { + Text = string.IsNullOrWhiteSpace(text) ? "—" : text, + FontSize = size, + FontWeight = weight, + Foreground = FatCommandBrush("#34465D"), + TextTrimming = TextTrimming.CharacterEllipsis, + VerticalAlignment = VerticalAlignment.Center + }; + + private static TextBlock FatCommandEmptyText(string text) + => new() + { + Text = text, + FontSize = 10.3, + Foreground = FatCommandBrush("#75859A"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(2, 4, 2, 4) + }; + + private static string FatCommandModelText(string? value) + { + var text = value?.Trim() ?? string.Empty; + if (text.Contains("select before operate", StringComparison.OrdinalIgnoreCase) || text.Contains("SBO", StringComparison.OrdinalIgnoreCase)) + return text.Contains("enhanced", StringComparison.OrdinalIgnoreCase) ? "SBO • Enhanced" : "SBO • Normal"; + if (text.Contains("direct", StringComparison.OrdinalIgnoreCase)) + return text.Contains("enhanced", StringComparison.OrdinalIgnoreCase) ? "Direct • Enhanced" : "Direct • Normal"; + if (text.Contains("status", StringComparison.OrdinalIgnoreCase)) + return "Status only"; + return string.IsNullOrWhiteSpace(text) ? "Reading…" : text; + } + + private static Brush FatCommandBrush(string value) + => new SolidColorBrush((Color)ColorConverter.ConvertFromString(value)); + + private static IEnumerable FindFatCommandVisualChildren(DependencyObject root) where T : DependencyObject + { + var count = VisualTreeHelper.GetChildrenCount(root); + for (var i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (child is T match) + yield return match; + foreach (var descendant in FindFatCommandVisualChildren(child)) + yield return descendant; + } + } +} diff --git a/IoListTestingWindow.FatFieldUx.cs b/IoListTestingWindow.FatFieldUx.cs new file mode 100644 index 000000000..4aff1d068 --- /dev/null +++ b/IoListTestingWindow.FatFieldUx.cs @@ -0,0 +1,303 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Bench-focused FAT UX hardening that is intentionally additive to the existing +/// report-only acquisition and command execution paths. No MMS/RCB/DataSet behavior +/// is changed here. +/// +public partial class IoListTestingWindow +{ + private bool _fatFieldUxInstalled; + private bool _fatStopLayoutInstalled; + private Popup? _fatCommandFailureShout; + private TextBlock? _fatCommandFailureShoutText; + private DispatcherTimer? _fatCommandFailureShoutTimer; + private Iec61850MonitorDevice? _fatFieldCommandDevice; + private readonly HashSet _fatFieldSubscribedSignals = new(); + private readonly HashSet _fatFieldDefaultsInitialized = new(); + + private static readonly bool FatFieldUxClassHandlerRegistered = RegisterFatFieldUxClassHandler(); + + private static bool RegisterFatFieldUxClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(FatFieldUxClassLoaded)); + return true; + } + + private static void FatFieldUxClassLoaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._fatFieldUxInstalled) + return; + + window._fatFieldUxInstalled = true; + window.PropertyChanged += window.FatFieldUxWindow_PropertyChanged; + window.Closed += window.FatFieldUxWindow_Closed; + + // The existing CommandPanel partial installs itself at ContextIdle. Run after it + // so this additive layer can use the same panel shell and shared command device. + window.Dispatcher.BeginInvoke( + new Action(window.InstallFatFieldUx), + DispatcherPriority.ApplicationIdle); + } + + private void InstallFatFieldUx() + { + InstallFatStopLayout(); + InstallFatCommandFailureShout(); + RefreshFatFieldCommandSubscriptions(); + } + + private void FatFieldUxWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedIed)) + return; + + Dispatcher.BeginInvoke( + new Action(() => + { + InstallFatStopLayout(); + InstallFatCommandFailureShout(); + RefreshFatFieldCommandSubscriptions(); + }), + DispatcherPriority.ApplicationIdle); + } + + private void FatFieldUxWindow_Closed(object? sender, EventArgs e) + { + PropertyChanged -= FatFieldUxWindow_PropertyChanged; + Closed -= FatFieldUxWindow_Closed; + DetachFatFieldCommandDevice(); + _fatCommandFailureShoutTimer?.Stop(); + if (_fatCommandFailureShout != null) + _fatCommandFailureShout.IsOpen = false; + } + + /// + /// Keep the critical Stop action in its own reserved Grid column. The surrounding + /// action strip is compacted, but the existing Stop button instance is moved rather + /// than recreated so its command binding, click handler and lifecycle semantics stay + /// exactly the same. + /// + private void InstallFatStopLayout() + { + if (_fatStopLayoutInstalled) + return; + + var stop = FindFatCommandVisualChildren