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 @@
+
+
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