diff --git a/FaultRecordWindow.HeaderSelection.cs b/FaultRecordWindow.HeaderSelection.cs
new file mode 100644
index 00000000..1540a685
--- /dev/null
+++ b/FaultRecordWindow.HeaderSelection.cs
@@ -0,0 +1,196 @@
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Automation;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Threading;
+
+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.
+///
+public partial class FaultRecordWindow
+{
+ private static readonly bool FaultRecordHeaderSelectionClassHandlerRegistered =
+ RegisterFaultRecordHeaderSelectionClassHandler();
+
+ private readonly HashSet _faultRecordHeaderObservedRows = new();
+ private bool _faultRecordHeaderSelectionInstallScheduled;
+ private bool _faultRecordHeaderSelectionInstalled;
+ private bool _faultRecordHeaderBulkUpdate;
+ private CheckBox? _faultRecordHeaderSelectionCheckBox;
+
+ private static bool RegisterFaultRecordHeaderSelectionClassHandler()
+ {
+ EventManager.RegisterClassHandler(
+ typeof(FaultRecordWindow),
+ FrameworkElement.LoadedEvent,
+ new RoutedEventHandler(OnFaultRecordHeaderSelectionWindowLoaded),
+ handledEventsToo: true);
+ return true;
+ }
+
+ private static void OnFaultRecordHeaderSelectionWindowLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not FaultRecordWindow window ||
+ window._faultRecordHeaderSelectionInstalled ||
+ window._faultRecordHeaderSelectionInstallScheduled)
+ {
+ return;
+ }
+
+ window._faultRecordHeaderSelectionInstallScheduled = true;
+ window.Dispatcher.BeginInvoke(
+ DispatcherPriority.ContextIdle,
+ new Action(() =>
+ {
+ window._faultRecordHeaderSelectionInstallScheduled = false;
+ window.EnsureFaultRecordHeaderSelection();
+ }));
+ }
+
+ private void EnsureFaultRecordHeaderSelection()
+ {
+ if (_faultRecordHeaderSelectionInstalled)
+ {
+ RefreshFaultRecordHeaderSelection();
+ return;
+ }
+
+ var column = FaultRecordsGrid.Columns.FirstOrDefault(candidate =>
+ string.Equals(candidate.Header?.ToString(), "Get", StringComparison.OrdinalIgnoreCase));
+ if (column == null)
+ return;
+
+ var headerCheckBox = new CheckBox
+ {
+ Width = 16,
+ Height = 16,
+ IsThreeState = true,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Cursor = Cursors.Hand,
+ Focusable = true,
+ ToolTip = "Check / uncheck all downloadable fault records"
+ };
+ AutomationProperties.SetName(headerCheckBox, "Toggle all downloadable fault records");
+ headerCheckBox.Click += FaultRecordHeaderSelectionCheckBox_Click;
+
+ column.Header = headerCheckBox;
+ _faultRecordHeaderSelectionCheckBox = headerCheckBox;
+ _faultRecordHeaderSelectionInstalled = true;
+
+ PropertyChanged += FaultRecordHeaderSelectionWindow_PropertyChanged;
+ Records.CollectionChanged += FaultRecordHeaderSelectionRecords_CollectionChanged;
+ Closed += FaultRecordHeaderSelectionWindow_Closed;
+ RewireFaultRecordHeaderRows();
+ RefreshFaultRecordHeaderSelection();
+ }
+
+ private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEventArgs e)
+ {
+ if (IsBusy)
+ {
+ RefreshFaultRecordHeaderSelection();
+ return;
+ }
+
+ var target = GetFaultRecordHeaderSelectionState() != true;
+ _faultRecordHeaderBulkUpdate = true;
+ try
+ {
+ foreach (var row in Records)
+ {
+ if (target)
+ row.IsSelected = row.CanSelectForDownload;
+ else
+ row.IsSelected = false;
+ }
+ }
+ finally
+ {
+ _faultRecordHeaderBulkUpdate = false;
+ }
+
+ RaiseSelectionState();
+ RefreshFaultRecordHeaderSelection();
+ }
+
+ private void FaultRecordHeaderSelectionWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ => RefreshFaultRecordHeaderSelection();
+
+ private void FaultRecordHeaderSelectionRecords_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ RewireFaultRecordHeaderRows();
+ RefreshFaultRecordHeaderSelection();
+ }
+
+ private void RewireFaultRecordHeaderRows()
+ {
+ var current = Records.ToHashSet();
+ foreach (var stale in _faultRecordHeaderObservedRows.Where(row => !current.Contains(row)).ToArray())
+ {
+ stale.PropertyChanged -= FaultRecordHeaderSelectionRow_PropertyChanged;
+ _faultRecordHeaderObservedRows.Remove(stale);
+ }
+
+ foreach (var row in Records)
+ {
+ if (!_faultRecordHeaderObservedRows.Add(row))
+ continue;
+
+ row.PropertyChanged += FaultRecordHeaderSelectionRow_PropertyChanged;
+ }
+ }
+
+ private void FaultRecordHeaderSelectionRow_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (_faultRecordHeaderBulkUpdate)
+ return;
+
+ RefreshFaultRecordHeaderSelection();
+ }
+
+ private void RefreshFaultRecordHeaderSelection()
+ {
+ var header = _faultRecordHeaderSelectionCheckBox;
+ if (header == null)
+ return;
+
+ var eligibleCount = Records.Count(row => row.CanSelectForDownload);
+ header.IsEnabled = !IsBusy && eligibleCount > 0;
+ header.IsChecked = GetFaultRecordHeaderSelectionState();
+ }
+
+ private bool? GetFaultRecordHeaderSelectionState()
+ {
+ var eligible = Records.Where(row => row.CanSelectForDownload).ToArray();
+ if (eligible.Length == 0)
+ return false;
+
+ var selected = eligible.Count(row => row.IsSelected);
+ if (selected == 0)
+ return false;
+ if (selected == eligible.Length)
+ return true;
+ return null;
+ }
+
+ private void FaultRecordHeaderSelectionWindow_Closed(object? sender, EventArgs e)
+ {
+ PropertyChanged -= FaultRecordHeaderSelectionWindow_PropertyChanged;
+ Records.CollectionChanged -= FaultRecordHeaderSelectionRecords_CollectionChanged;
+ Closed -= FaultRecordHeaderSelectionWindow_Closed;
+
+ foreach (var row in _faultRecordHeaderObservedRows)
+ row.PropertyChanged -= FaultRecordHeaderSelectionRow_PropertyChanged;
+ _faultRecordHeaderObservedRows.Clear();
+
+ if (_faultRecordHeaderSelectionCheckBox != null)
+ _faultRecordHeaderSelectionCheckBox.Click -= FaultRecordHeaderSelectionCheckBox_Click;
+ }
+}
diff --git a/Iec61850TimestampPresentation.cs b/Iec61850TimestampPresentation.cs
index e182f0ed..33e28dc2 100644
--- a/Iec61850TimestampPresentation.cs
+++ b/Iec61850TimestampPresentation.cs
@@ -41,6 +41,44 @@ public static string FormatMilliseconds(DateTimeOffset? value, string format, st
public static string FormatMilliseconds(DateTime value, string format)
=> RoundToNearestMillisecond(value).ToString(format, CultureInfo.InvariantCulture);
+ ///
+ /// Formats a full-resolution timestamp string for a compact live-grid display while
+ /// leaving the source string untouched for evidence, search and full-precision tooltips.
+ /// Non-timestamp values are returned unchanged rather than guessed.
+ ///
+ public static string FormatMilliseconds(
+ string? value,
+ string format = "yyyy-MM-dd HH:mm:ss.fff",
+ string missing = "-")
+ {
+ var text = (value ?? string.Empty).Trim();
+ if (text.Length == 0 || text == "-")
+ return text.Length == 0 ? missing : text;
+
+ // Relay timestamps in the live workspace normally have no explicit offset.
+ // Parse those as DateTime first so presentation never invents or shifts a zone.
+ if (DateTime.TryParse(
+ text,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.RoundtripKind,
+ out var dateTime))
+ {
+ return FormatMilliseconds(dateTime, format);
+ }
+
+ // Preserve explicit-offset timestamps when they are supplied by another surface.
+ if (DateTimeOffset.TryParse(
+ text,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AllowWhiteSpaces,
+ out var dateTimeOffset))
+ {
+ return FormatMilliseconds(dateTimeOffset, format);
+ }
+
+ return text;
+ }
+
private static long MillisecondDelta(long ticks)
{
var remainder = ticks % TicksPerMillisecond;
diff --git a/IoListTestingWindow.HeaderSelection.cs b/IoListTestingWindow.HeaderSelection.cs
new file mode 100644
index 00000000..944f689e
--- /dev/null
+++ b/IoListTestingWindow.HeaderSelection.cs
@@ -0,0 +1,208 @@
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Automation;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Threading;
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester;
+
+///
+/// Adds a compact tri-state header checkbox to the FAT TEST column without changing
+/// the grid item source or the per-row TestEnabled contract. The header uses the same
+/// editability gate as the row checkboxes and persists one bulk plan change.
+///
+public partial class IoListTestingWindow
+{
+ private static readonly bool IoTestHeaderSelectionClassHandlerRegistered =
+ RegisterIoTestHeaderSelectionClassHandler();
+
+ private bool _ioTestHeaderSelectionInstallScheduled;
+ private bool _ioTestHeaderSelectionInstalled;
+ private CheckBox? _ioTestHeaderSelectionCheckBox;
+ private IoTestIedPlan? _ioTestHeaderObservedIed;
+
+ private static bool RegisterIoTestHeaderSelectionClassHandler()
+ {
+ EventManager.RegisterClassHandler(
+ typeof(IoListTestingWindow),
+ FrameworkElement.LoadedEvent,
+ new RoutedEventHandler(OnIoTestHeaderSelectionWindowLoaded),
+ handledEventsToo: true);
+ return true;
+ }
+
+ private static void OnIoTestHeaderSelectionWindowLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not IoListTestingWindow window ||
+ window._ioTestHeaderSelectionInstalled ||
+ window._ioTestHeaderSelectionInstallScheduled)
+ {
+ return;
+ }
+
+ window._ioTestHeaderSelectionInstallScheduled = true;
+ window.Dispatcher.BeginInvoke(
+ DispatcherPriority.ContextIdle,
+ new Action(() =>
+ {
+ window._ioTestHeaderSelectionInstallScheduled = false;
+ window.EnsureIoTestHeaderSelection();
+ }));
+ }
+
+ private void EnsureIoTestHeaderSelection()
+ {
+ if (_ioTestHeaderSelectionInstalled)
+ {
+ RefreshIoTestHeaderSelection();
+ return;
+ }
+
+ var grid = FindIoTestSelectionGrid(this);
+ if (grid == null)
+ return;
+
+ var column = grid.Columns.FirstOrDefault(candidate =>
+ string.Equals(candidate.Header?.ToString(), "TEST", StringComparison.OrdinalIgnoreCase));
+ if (column == null)
+ return;
+
+ var headerCheckBox = new CheckBox
+ {
+ Width = 16,
+ Height = 16,
+ IsThreeState = true,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Cursor = Cursors.Hand,
+ Focusable = true,
+ ToolTip = "Check / uncheck all TEST rows for the selected IED"
+ };
+ AutomationProperties.SetName(headerCheckBox, "Toggle all FAT test rows");
+ headerCheckBox.Click += IoTestHeaderSelectionCheckBox_Click;
+
+ column.Header = headerCheckBox;
+ _ioTestHeaderSelectionCheckBox = headerCheckBox;
+ _ioTestHeaderSelectionInstalled = true;
+
+ PropertyChanged += IoTestHeaderSelectionWindow_PropertyChanged;
+ Closed += IoTestHeaderSelectionWindow_Closed;
+ RewireIoTestHeaderObservedIed();
+ RefreshIoTestHeaderSelection();
+ }
+
+ private void IoTestHeaderSelectionCheckBox_Click(object sender, RoutedEventArgs e)
+ {
+ var ied = SelectedIed;
+ if (ied == null || !CanEditPlan)
+ {
+ RefreshIoTestHeaderSelection();
+ return;
+ }
+
+ var target = GetIoTestHeaderSelectionState(ied) != true;
+ foreach (var point in ied.TestPoints)
+ point.TestEnabled = target;
+
+ Storage?.ScheduleSave();
+ Raise(nameof(SelectedIedSummary));
+ RaiseSelectedIedContextProperties();
+ RefreshIoTestHeaderSelection();
+ }
+
+ private void IoTestHeaderSelectionWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(SelectedIed))
+ RewireIoTestHeaderObservedIed();
+
+ RefreshIoTestHeaderSelection();
+ }
+
+ private void IoTestHeaderObservedIed_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ => RefreshIoTestHeaderSelection();
+
+ private void RewireIoTestHeaderObservedIed()
+ {
+ if (ReferenceEquals(_ioTestHeaderObservedIed, SelectedIed))
+ return;
+
+ if (_ioTestHeaderObservedIed != null)
+ _ioTestHeaderObservedIed.PropertyChanged -= IoTestHeaderObservedIed_PropertyChanged;
+
+ _ioTestHeaderObservedIed = SelectedIed;
+ if (_ioTestHeaderObservedIed != null)
+ _ioTestHeaderObservedIed.PropertyChanged += IoTestHeaderObservedIed_PropertyChanged;
+ }
+
+ private void RefreshIoTestHeaderSelection()
+ {
+ var header = _ioTestHeaderSelectionCheckBox;
+ if (header == null)
+ return;
+
+ var ied = SelectedIed;
+ header.IsEnabled = CanEditPlan && ied?.TestPoints.Count > 0;
+ header.IsChecked = ied == null ? false : GetIoTestHeaderSelectionState(ied);
+ }
+
+ private static bool? GetIoTestHeaderSelectionState(IoTestIedPlan ied)
+ {
+ if (ied.TestPoints.Count == 0)
+ return false;
+
+ var enabled = ied.TestPoints.Count(point => point.TestEnabled);
+ if (enabled == 0)
+ return false;
+ if (enabled == ied.TestPoints.Count)
+ return true;
+ return null;
+ }
+
+ private void IoTestHeaderSelectionWindow_Closed(object? sender, EventArgs e)
+ {
+ PropertyChanged -= IoTestHeaderSelectionWindow_PropertyChanged;
+ Closed -= IoTestHeaderSelectionWindow_Closed;
+
+ if (_ioTestHeaderObservedIed != null)
+ _ioTestHeaderObservedIed.PropertyChanged -= IoTestHeaderObservedIed_PropertyChanged;
+ _ioTestHeaderObservedIed = null;
+
+ if (_ioTestHeaderSelectionCheckBox != null)
+ _ioTestHeaderSelectionCheckBox.Click -= IoTestHeaderSelectionCheckBox_Click;
+ }
+
+ private static DataGrid? FindIoTestSelectionGrid(DependencyObject root)
+ {
+ foreach (var grid in FindIoTestHeaderVisualChildren(root))
+ {
+ if (grid.Columns.Any(column =>
+ string.Equals(column.Header?.ToString(), "TEST", StringComparison.OrdinalIgnoreCase)))
+ {
+ return grid;
+ }
+ }
+
+ return null;
+ }
+
+ private static IEnumerable FindIoTestHeaderVisualChildren(DependencyObject root)
+ where T : DependencyObject
+ {
+ if (root == null)
+ yield break;
+
+ var count = VisualTreeHelper.GetChildrenCount(root);
+ for (var index = 0; index < count; index++)
+ {
+ var child = VisualTreeHelper.GetChild(root, index);
+ if (child is T match)
+ yield return match;
+
+ foreach (var descendant in FindIoTestHeaderVisualChildren(child))
+ yield return descendant;
+ }
+ }
+}
diff --git a/MainWindow.ExplorerSignalGrid.cs b/MainWindow.ExplorerSignalGrid.cs
index 155ccbba..6d7391cb 100644
--- a/MainWindow.ExplorerSignalGrid.cs
+++ b/MainWindow.ExplorerSignalGrid.cs
@@ -1,7 +1,13 @@
+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.Media.Effects;
using System.Windows.Threading;
+using ArIED61850Tester.Models;
+using WpfToolTip = System.Windows.Controls.ToolTip;
namespace ArIED61850Tester;
@@ -52,6 +58,71 @@ private void ConfigureExplorerSignalGridForCompactFit()
signalGrid.Columns[index].MinWidth = minimums[index];
signalGrid.Columns[index].Width = new DataGridLength(weights[index], DataGridLengthUnitType.Star);
}
+
+ ConfigureExplorerTimestampPresentation(signalGrid);
+ }
+
+ private static void ConfigureExplorerTimestampPresentation(DataGrid signalGrid)
+ {
+ if (signalGrid.Columns.Count <= 4 || signalGrid.Columns[4] is not DataGridTextColumn timestampColumn)
+ return;
+
+ // Display only is rounded to nearest millisecond. The monitor point keeps the
+ // original full-resolution timestamp for evidence, search and hover detail.
+ timestampColumn.Binding = new Binding(nameof(Iec61850MonitorPoint.DeviceTimestamp))
+ {
+ Converter = ExplorerTimestampMillisecondsConverter.Instance,
+ Mode = BindingMode.OneWay
+ };
+
+ var timestampTextStyle = new Style(typeof(TextBlock), timestampColumn.ElementStyle);
+ timestampTextStyle.Setters.Add(new Setter(
+ FrameworkElement.ToolTipProperty,
+ new Binding(nameof(Iec61850MonitorPoint.DeviceTimestamp))
+ {
+ Converter = ExplorerTimestampFullPrecisionToolTipConverter.Instance,
+ Mode = BindingMode.OneWay
+ }));
+ timestampTextStyle.Setters.Add(new Setter(ToolTipService.ShowDurationProperty, 30000));
+ timestampTextStyle.Setters.Add(new Setter(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis));
+ timestampColumn.ElementStyle = timestampTextStyle;
+
+ // Scope the premium tooltip styling to this live-value DataGrid so unrelated
+ // application tooltips retain their established appearance.
+ signalGrid.Resources[typeof(WpfToolTip)] = BuildExplorerTimestampToolTipStyle();
+ }
+
+ private static Style BuildExplorerTimestampToolTipStyle()
+ {
+ var style = new Style(typeof(WpfToolTip));
+ style.Setters.Add(new Setter(Control.ForegroundProperty, Brushes.White));
+ style.Setters.Add(new Setter(Control.FontSizeProperty, 11.4));
+ style.Setters.Add(new Setter(Control.FontWeightProperty, FontWeights.Medium));
+ style.Setters.Add(new Setter(WpfToolTip.PlacementProperty, PlacementMode.Mouse));
+ style.Setters.Add(new Setter(WpfToolTip.HorizontalOffsetProperty, 10d));
+ style.Setters.Add(new Setter(WpfToolTip.VerticalOffsetProperty, 12d));
+
+ var template = new ControlTemplate(typeof(WpfToolTip));
+ var chrome = new FrameworkElementFactory(typeof(Border));
+ chrome.SetValue(Border.BackgroundProperty, new SolidColorBrush(Color.FromRgb(35, 49, 59)));
+ chrome.SetValue(Border.BorderBrushProperty, new SolidColorBrush(Color.FromRgb(91, 111, 123)));
+ chrome.SetValue(Border.BorderThicknessProperty, new Thickness(1));
+ chrome.SetValue(Border.CornerRadiusProperty, new CornerRadius(8));
+ chrome.SetValue(Border.PaddingProperty, new Thickness(11, 8, 11, 8));
+ chrome.SetValue(Border.EffectProperty, new DropShadowEffect
+ {
+ BlurRadius = 16,
+ ShadowDepth = 4,
+ Opacity = 0.20,
+ Color = Color.FromRgb(15, 23, 42)
+ });
+
+ var content = new FrameworkElementFactory(typeof(ContentPresenter));
+ content.SetValue(ContentPresenter.RecognizesAccessKeyProperty, false);
+ chrome.AppendChild(content);
+ template.VisualTree = chrome;
+ style.Setters.Add(new Setter(Control.TemplateProperty, template));
+ return style;
}
private static IEnumerable FindExplorerVisualChildren(DependencyObject? root)
@@ -71,3 +142,30 @@ private static IEnumerable FindExplorerVisualChildren(DependencyObject? ro
}
}
}
+
+internal sealed class ExplorerTimestampMillisecondsConverter : IValueConverter
+{
+ public static ExplorerTimestampMillisecondsConverter Instance { get; } = new();
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ => Iec61850TimestampPresentation.FormatMilliseconds(value as string);
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
+
+internal sealed class ExplorerTimestampFullPrecisionToolTipConverter : IValueConverter
+{
+ public static ExplorerTimestampFullPrecisionToolTipConverter Instance { get; } = new();
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ var text = (value as string)?.Trim();
+ return string.IsNullOrWhiteSpace(text) || text == "-"
+ ? "IED TIMESTAMP · FULL PRECISION\nNo timestamp available"
+ : $"IED TIMESTAMP · FULL PRECISION\n{text}";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs
index c6aab0b1..008c6612 100644
--- a/MainWindow.IoTesting.AutoConnect.cs
+++ b/MainWindow.IoTesting.AutoConnect.cs
@@ -136,6 +136,12 @@ void ReportProgress(string message)
ReportProgress($"{ied.IedName} association ready · reusing the loaded model");
}
+ // Reconciliation belongs to the connection/discovery lifecycle, before local
+ // FAT row selection. P1.2 remains probe:null inside the cache; P1.3 can later
+ // replace only that producer with the engine-owned connected facade.
+ ReportProgress("Reconciling SCL design with authoritative live model");
+ await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token);
+
// Never let a runtime anchor from an earlier model silently decide a fresh
// FAT preparation. Re-prove every requested row against the current model;
// successful smart matches are anchored again immediately below.
@@ -175,6 +181,9 @@ void ReportProgress(string message)
}
usedSavedModel = false;
+ ReportProgress("Reconciling refreshed live model with SCL design");
+ await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token);
+
foreach (var point in requestedPoints)
{
point.ApplyLiveBinding(
diff --git a/MainWindow.LiveSignalSearch.cs b/MainWindow.LiveSignalSearch.cs
new file mode 100644
index 00000000..8250a27a
--- /dev/null
+++ b/MainWindow.LiveSignalSearch.cs
@@ -0,0 +1,313 @@
+using System.Collections;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Input;
+using System.Windows.Media;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester;
+
+///
+/// Lightweight, presentation-only search for the Explorer live-value workspace.
+/// Filtering is performed over the existing ICollectionView; it never changes the
+/// monitored point collection, acquisition lifecycle, or IEC 61850 network traffic.
+///
+public partial class MainWindow
+{
+ private static readonly bool LiveSignalSearchClassHandlerRegistered = RegisterLiveSignalSearchClassHandler();
+
+ private DataGrid? _liveSignalSearchGrid;
+ private TextBox? _liveSignalSearchBox;
+ private TextBlock? _liveSignalSearchCount;
+ private ICollectionView? _liveSignalSearchView;
+ private INotifyCollectionChanged? _liveSignalSearchCollection;
+ private DependencyPropertyDescriptor? _liveSignalItemsSourceDescriptor;
+ private bool _liveSignalSearchInstalled;
+
+ private static bool RegisterLiveSignalSearchClassHandler()
+ {
+ EventManager.RegisterClassHandler(
+ typeof(MainWindow),
+ FrameworkElement.LoadedEvent,
+ new RoutedEventHandler(LiveSignalSearch_MainWindowLoaded));
+ return true;
+ }
+
+ private static void LiveSignalSearch_MainWindowLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MainWindow window || !ReferenceEquals(e.OriginalSource, window))
+ return;
+
+ window.Dispatcher.BeginInvoke(new Action(window.InstallLiveSignalSearch));
+ }
+
+ private void InstallLiveSignalSearch()
+ {
+ if (_liveSignalSearchInstalled)
+ return;
+
+ var dataGrid = FindLiveSignalVisualChildren(this)
+ .FirstOrDefault(IsExplorerLiveSignalGrid);
+ if (dataGrid?.Parent is not Grid host)
+ return;
+
+ _liveSignalSearchInstalled = true;
+ _liveSignalSearchGrid = dataGrid;
+
+ // The existing host contains the DataGrid plus its empty-workspace overlay.
+ // Give both row 1 and reserve a compact row 0 for the search toolbar.
+ if (host.RowDefinitions.Count == 0)
+ {
+ host.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
+ foreach (UIElement child in host.Children.Cast().ToArray())
+ Grid.SetRow(child, 1);
+ }
+
+ var toolbar = BuildLiveSignalSearchToolbar();
+ Grid.SetRow(toolbar, 0);
+ host.Children.Add(toolbar);
+
+ _liveSignalItemsSourceDescriptor = DependencyPropertyDescriptor.FromProperty(
+ ItemsControl.ItemsSourceProperty,
+ typeof(DataGrid));
+ _liveSignalItemsSourceDescriptor?.AddValueChanged(dataGrid, LiveSignalSearch_ItemsSourceChanged);
+
+ PreviewKeyDown += LiveSignalSearch_WindowPreviewKeyDown;
+ Closed += LiveSignalSearch_WindowClosed;
+ AttachLiveSignalSearchSource();
+ }
+
+ private FrameworkElement BuildLiveSignalSearchToolbar()
+ {
+ var toolbar = new Grid
+ {
+ Margin = new Thickness(2, 0, 2, 9),
+ Height = 34
+ };
+ toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+
+ var titlePanel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ titlePanel.Children.Add(new Border
+ {
+ Width = 7,
+ Height = 7,
+ CornerRadius = new CornerRadius(3.5),
+ Background = new SolidColorBrush(Color.FromRgb(37, 99, 235)),
+ Margin = new Thickness(2, 0, 8, 0)
+ });
+ titlePanel.Children.Add(new TextBlock
+ {
+ Text = "LIVE SIGNAL VALUES",
+ FontSize = 11.2,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = new SolidColorBrush(Color.FromRgb(70, 86, 109)),
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ _liveSignalSearchCount = new TextBlock
+ {
+ Text = string.Empty,
+ FontSize = 10.8,
+ Foreground = new SolidColorBrush(Color.FromRgb(126, 142, 165)),
+ Margin = new Thickness(9, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ titlePanel.Children.Add(_liveSignalSearchCount);
+ toolbar.Children.Add(titlePanel);
+
+ _liveSignalSearchBox = new TextBox
+ {
+ Width = 390,
+ Style = ResolveIndustrialSearchStyle(),
+ Tag = "Search signal, IEC reference, value, quality or acquisition",
+ ToolTip = "Filter the current live workspace. Ctrl+F focuses search; Esc clears it.",
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ _liveSignalSearchBox.TextChanged += LiveSignalSearch_TextChanged;
+ _liveSignalSearchBox.PreviewKeyDown += LiveSignalSearch_BoxPreviewKeyDown;
+ Grid.SetColumn(_liveSignalSearchBox, 1);
+ toolbar.Children.Add(_liveSignalSearchBox);
+
+ return toolbar;
+ }
+
+ private Style? ResolveIndustrialSearchStyle()
+ {
+ var style = TryFindResource("IndustrialSearchTextBox") as Style
+ ?? Application.Current?.TryFindResource("IndustrialSearchTextBox") as Style;
+ if (style != null)
+ return style;
+
+ // Defensive fallback for load ordering: use the same resource dictionary that
+ // P2IndustrialWorkstationUx uses for the IED Explorer search field.
+ var dictionary = new ResourceDictionary
+ {
+ Source = new Uri("/ARSAS;component/Resources/P2IndustrialControls.xaml", UriKind.Relative)
+ };
+ return dictionary["IndustrialSearchTextBox"] as Style;
+ }
+
+ private static bool IsExplorerLiveSignalGrid(DataGrid grid)
+ {
+ var headers = grid.Columns
+ .Select(column => column.Header?.ToString() ?? string.Empty)
+ .ToArray();
+
+ // This six-column signature is unique to the selected-IED live-value workspace.
+ // The global monitor has an additional IED column and the command grid has a
+ // different schema, so no visual-tree TabItem assumptions are required.
+ return headers.Length == 6 &&
+ headers[0].Equals("Signal", StringComparison.OrdinalIgnoreCase) &&
+ headers[1].Equals("IEC Telegram", StringComparison.OrdinalIgnoreCase) &&
+ headers[2].Equals("Value", StringComparison.OrdinalIgnoreCase) &&
+ headers[3].Equals("Quality", StringComparison.OrdinalIgnoreCase) &&
+ headers[4].Equals("IED Timestamp", StringComparison.OrdinalIgnoreCase) &&
+ headers[5].Equals("Acquisition", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void LiveSignalSearch_ItemsSourceChanged(object? sender, EventArgs e)
+ => AttachLiveSignalSearchSource();
+
+ private void AttachLiveSignalSearchSource()
+ {
+ if (_liveSignalSearchCollection != null)
+ _liveSignalSearchCollection.CollectionChanged -= LiveSignalSearch_CollectionChanged;
+ if (_liveSignalSearchView != null)
+ _liveSignalSearchView.Filter = null;
+
+ var source = _liveSignalSearchGrid?.ItemsSource;
+ _liveSignalSearchCollection = source as INotifyCollectionChanged;
+ if (_liveSignalSearchCollection != null)
+ _liveSignalSearchCollection.CollectionChanged += LiveSignalSearch_CollectionChanged;
+
+ _liveSignalSearchView = source == null
+ ? null
+ : CollectionViewSource.GetDefaultView(source);
+ ApplyLiveSignalSearch();
+ }
+
+ private void LiveSignalSearch_TextChanged(object sender, TextChangedEventArgs e)
+ => ApplyLiveSignalSearch();
+
+ private void LiveSignalSearch_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ => Dispatcher.BeginInvoke(new Action(UpdateLiveSignalSearchCount));
+
+ private void ApplyLiveSignalSearch()
+ {
+ if (_liveSignalSearchView == null)
+ {
+ UpdateLiveSignalSearchCount();
+ return;
+ }
+
+ _liveSignalSearchView.Filter = LiveSignalSearch_Matches;
+ _liveSignalSearchView.Refresh();
+ UpdateLiveSignalSearchCount();
+ }
+
+ private bool LiveSignalSearch_Matches(object item)
+ {
+ var query = (_liveSignalSearchBox?.Text ?? string.Empty).Trim();
+ if (query.Length == 0)
+ return true;
+ if (item is not Iec61850MonitorPoint point)
+ return true;
+
+ var searchable = string.Join('\n', new[]
+ {
+ point.DeviceName,
+ point.SignalName,
+ point.IecTelegram,
+ point.IecReference,
+ point.Value,
+ point.Quality,
+ point.DeviceTimestamp,
+ point.SourceMode
+ }.Where(value => !string.IsNullOrWhiteSpace(value)));
+
+ // Multiple terms use AND semantics: "MMXU instCVal" quickly narrows large workspaces.
+ var tokens = query.Split(
+ new[] { ' ', '\t', '\r', '\n' },
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return tokens.All(token => searchable.Contains(token, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private void UpdateLiveSignalSearchCount()
+ {
+ if (_liveSignalSearchCount == null)
+ return;
+
+ var source = _liveSignalSearchGrid?.ItemsSource;
+ var total = source switch
+ {
+ ICollection collection => collection.Count,
+ IEnumerable