diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml
index dc1fa255..4461dc6f 100644
--- a/src/ProcessBus.App.Wpf/MainWindow.xaml
+++ b/src/ProcessBus.App.Wpf/MainWindow.xaml
@@ -1404,57 +1404,35 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ MouseMove="GooseTraffic_Interaction">
diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs
index bb723db5..186459d5 100644
--- a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs
+++ b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs
@@ -1,3 +1,7 @@
+using ProcessBus.Core.Presentation;
+using System.Collections.Specialized;
+using System.Linq;
+using System.Windows.Threading;
using ProcessBus.App.Wpf.ViewModels;
using ProcessBus.App.Wpf.Views;
using System.ComponentModel;
@@ -12,6 +16,13 @@ namespace ProcessBus.App.Wpf;
public partial class MainWindow : Window
{
private DateTime _lastGooseInteractionUtc = DateTime.MinValue;
+ private bool _gooseWorkspaceHooksAttached;
+ private bool _gooseSourceSyncing;
+ private bool _gooseSourceSyncQueued;
+ private bool _gooseSourceKeyboardSelecting;
+ private GoosePublisherKey? _gooseSourceRangeAnchor;
+ private bool _gooseFollowQueued;
+ private bool _gooseProgrammaticScroll;
private int _pendingWorkspaceTabIndex = -1;
private bool _isWorkspaceTabSwitchQueued;
private bool _shutdownStarted;
@@ -22,6 +33,7 @@ public MainWindow()
InitializeComponent();
if (DataContext is MainWindowViewModel viewModel) { }
+ Loaded += GooseWorkspace_Loaded;
Loaded += (_, _) => { UpdateHeaderDensity(); UpdateWorkspaceActivePill(animate: false); };
SizeChanged += (_, _) => UpdateHeaderDensity();
Closing += MainWindow_Closing;
@@ -266,14 +278,217 @@ private void GooseClearSearch_Click(object sender, RoutedEventArgs e)
GooseSourceSearchBox.Focus();
}
+ private void GooseWorkspace_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (_gooseWorkspaceHooksAttached || DataContext is not MainWindowViewModel vm) return;
+ _gooseWorkspaceHooksAttached = true;
+ vm.PropertyChanged += GooseViewModel_PropertyChanged;
+ vm.GoosePublisherRows.CollectionChanged += GooseSources_CollectionChanged;
+ vm.GooseVisibleMessagesAppended += GooseVisibleMessagesAppended;
+ QueueGooseSourceSync();
+ }
+
+ private void GooseViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(MainWindowViewModel.GoosePublisherFilterText)
+ or nameof(MainWindowViewModel.GooseSelectedSourceCount))
+ QueueGooseSourceSync();
+ }
+
+ private void GooseSources_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
+ QueueGooseSourceSync();
+
+ private void QueueGooseSourceSync()
+ {
+ if (_gooseSourceSyncQueued) return;
+ _gooseSourceSyncQueued = true;
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ _gooseSourceSyncQueued = false;
+ SyncGooseSourceSelection();
+ }), DispatcherPriority.Background);
+ }
+
+ private void SyncGooseSourceSelection()
+ {
+ if (_gooseSourceSyncing || DataContext is not MainWindowViewModel vm) return;
+ _gooseSourceSyncing = true;
+ try
+ {
+ var visible = GooseSourcesList.Items.OfType()
+ .Where(row => vm.SelectedGooseSourceKeys.Contains(row.Key)).ToArray();
+ var selected = GooseSourcesList.SelectedItems.OfType().ToArray();
+ if (visible.Length == selected.Length && visible.All(selected.Contains)) return;
+ GooseSourcesList.SelectedItems.Clear();
+ foreach (var row in visible)
+ GooseSourcesList.SelectedItems.Add(row);
+ }
+ finally { _gooseSourceSyncing = false; }
+ }
+
private void GoosePublisherRow_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
- // WPF SelectionChanged does not fire when the operator clicks an item
- // that is already selected. The explicit click still means "inspect live"
- // after a historical timeline event had been pinned.
- if (DataContext is MainWindowViewModel viewModel &&
- sender is ListViewItem { DataContext: ProcessBus.Core.Presentation.GoosePublisherRow row })
- viewModel.SelectedGoosePublisherRow = row;
+ if (DataContext is not MainWindowViewModel vm ||
+ sender is not ListViewItem { DataContext: GoosePublisherRow row })
+ return;
+
+ var chosen = vm.SelectedGooseSourceKeys.ToHashSet();
+ var modifier = Keyboard.Modifiers;
+ if ((modifier & ModifierKeys.Shift) != 0)
+ {
+ var visible = GooseSourcesList.Items.OfType().ToArray();
+ var anchor = Array.FindIndex(visible, item => item.Key == _gooseSourceRangeAnchor);
+ var target = Array.FindIndex(visible, item => item.Key == row.Key);
+ if (anchor < 0) anchor = target;
+ if (target >= 0)
+ for (var i = Math.Min(anchor, target); i <= Math.Max(anchor, target); i++)
+ chosen.Add(visible[i].Key);
+ }
+ else if ((modifier & ModifierKeys.Control) != 0)
+ {
+ if (!chosen.Add(row.Key)) chosen.Remove(row.Key);
+ _gooseSourceRangeAnchor = row.Key;
+ }
+ else
+ {
+ if (chosen.Count == 1 && chosen.Contains(row.Key)) chosen.Clear();
+ else { chosen.Clear(); chosen.Add(row.Key); }
+ _gooseSourceRangeAnchor = row.Key;
+ }
+
+ vm.SetGooseSourceSelection(chosen);
+ // Only plain click intentionally changes the live Inspector target.
+ if (modifier == ModifierKeys.None && chosen.Contains(row.Key))
+ vm.SelectedGoosePublisherRow = row;
+ SyncGooseSourceSelection();
+ ((ListViewItem)sender).Focus();
+ e.Handled = true; // WPF must not undo our explicit toggle/range semantics.
+ }
+
+ private void GooseSources_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key is Key.Up or Key.Down or Key.Home or Key.End or Key.PageUp
+ or Key.PageDown or Key.Space)
+ _gooseSourceKeyboardSelecting = true;
+ }
+
+ private void GooseSources_PreviewKeyUp(object sender, KeyEventArgs e)
+ {
+ _gooseSourceKeyboardSelecting = false;
+ }
+
+ private void GooseSources_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_gooseSourceSyncing || !_gooseSourceKeyboardSelecting ||
+ DataContext is not MainWindowViewModel vm) return;
+ // Hidden selected identities are retained when search narrows the left rail.
+ var visible = GooseSourcesList.Items.OfType()
+ .Select(row => row.Key).ToHashSet();
+ var keys = vm.SelectedGooseSourceKeys.Where(key => !visible.Contains(key)).ToHashSet();
+ foreach (var row in GooseSourcesList.SelectedItems.OfType())
+ keys.Add(row.Key);
+ vm.SetGooseSourceSelection(keys);
+ if (Keyboard.Modifiers == ModifierKeys.None &&
+ GooseSourcesList.SelectedItem is GoosePublisherRow focus)
+ vm.SelectedGoosePublisherRow = focus;
+ }
+
+ private void GooseVisibleMessagesAppended(GooseTrafficRow row)
+ {
+ if (DataContext is MainWindowViewModel { FollowGooseMessages: true })
+ QueueGooseFollow();
+ }
+
+ private void QueueGooseFollow()
+ {
+ if (_gooseFollowQueued || !GooseMessagesGrid.IsVisible) return;
+ _gooseFollowQueued = true;
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ _gooseFollowQueued = false;
+ if (DataContext is not MainWindowViewModel { FollowGooseMessages: true } vm ||
+ !GooseMessagesGrid.IsVisible) return;
+ var lastVisible = vm.GooseHistoryView.OfType().LastOrDefault();
+ if (lastVisible is null) return;
+ _gooseProgrammaticScroll = true;
+ GooseMessagesGrid.ScrollIntoView(lastVisible);
+ Dispatcher.BeginInvoke(new Action(() => _gooseProgrammaticScroll = false),
+ DispatcherPriority.ContextIdle);
+ }), DispatcherPriority.Loaded);
+ }
+
+ private void GooseFollowLive_Click(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+ vm.FollowGooseMessages = true;
+ QueueGooseFollow();
+ }
+
+ private void GooseMessagesGrid_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (e.NewValue is true &&
+ DataContext is MainWindowViewModel { FollowGooseMessages: true })
+ QueueGooseFollow();
+ }
+
+ private void SuspendGooseFollow()
+ {
+ if (!_gooseProgrammaticScroll && DataContext is MainWindowViewModel vm)
+ vm.FollowGooseMessages = false;
+ }
+
+ private void GooseMessagesGrid_PreviewMouseWheel(object sender, MouseWheelEventArgs e) =>
+ SuspendGooseFollow();
+
+ private void GooseMessagesGrid_PreviewMouseDown(object sender, MouseButtonEventArgs e)
+ {
+ // Preserve the pre-existing bounded operator-interaction flush deferral.
+ DeferGooseTrafficFlush();
+ SuspendGooseFollow();
+ }
+
+ private void GooseMessagesGrid_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key is Key.Up or Key.Down or Key.PageUp or Key.PageDown or
+ Key.Home or Key.End or Key.Space)
+ SuspendGooseFollow();
+ }
+
+ private void GooseMessagesGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (e.AddedItems.Count > 0) SuspendGooseFollow();
+ }
+
+ private void GooseMessagesGrid_ContextMenuOpening(object sender, ContextMenuEventArgs e) =>
+ SuspendGooseFollow();
+
+ private void GooseMessagesGrid_ScrollChanged(object sender, ScrollChangedEventArgs e)
+ {
+ // Real scrollbar/touch navigation away from bottom is an operator action.
+ // Content growth and programmatic follow do not suspend the follow state.
+ if (!_gooseProgrammaticScroll && e.ExtentHeightChange == 0 &&
+ Math.Abs(e.VerticalChange) > 0.01 &&
+ e.VerticalOffset < e.ExtentHeight - e.ViewportHeight - 2)
+ SuspendGooseFollow();
+ }
+
+ private void GooseMessagesGrid_LoadingRow(object sender, DataGridRowEventArgs e)
+ {
+ // Recycling must never transfer a previous event's visual accent.
+ e.Row.ClearValue(Control.BackgroundProperty);
+ if (e.Row.Item is not GooseTrafficRow row ||
+ row.EventType == "Retrans" ||
+ row.VisibleSinceUtc == DateTime.MinValue ||
+ DateTime.UtcNow - row.VisibleSinceUtc > TimeSpan.FromSeconds(1.5) ||
+ e.Row.IsSelected) return;
+ var brush = new SolidColorBrush(Color.FromArgb(53, 41, 125, 179));
+ e.Row.Background = brush;
+ brush.BeginAnimation(SolidColorBrush.ColorProperty, new ColorAnimation
+ {
+ To = Color.FromArgb(0, 41, 125, 179),
+ Duration = new Duration(TimeSpan.FromSeconds(1.25)),
+ FillBehavior = FillBehavior.HoldEnd
+ });
}
private void GooseTraffic_Interaction(object sender, MouseEventArgs e)
diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
index b4ab56c8..eede611a 100644
--- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
+++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
@@ -45,6 +45,10 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable
private SvStreamItem? _selectedStream;
private readonly GoosePublisherCatalog _gooseCatalog = new();
private GoosePublisherKey? _selectedGoosePublisherKey;
+ // Independent filter SSOT; inspector live focus and pinned historical event remain separate.
+ private readonly HashSet _selectedGooseSourceKeys = new();
+ private bool _followGooseMessages = true;
+ public event Action? GooseVisibleMessagesAppended;
private GooseTrafficRow? _selectedGooseTrafficRow;
private bool _gooseInspectHistoricalEvent;
private bool _gooseMessageExpanded = true;
@@ -184,6 +188,39 @@ public DiagnosticEventItem? SelectedDiagnosticEvent
public ObservableCollection GooseHistory => _gooseHistory;
public ICollectionView GooseHistoryView => _gooseHistoryView;
public ICollectionView GoosePublisherRowsView => _goosePublisherRowsView;
+ public IReadOnlySet SelectedGooseSourceKeys => _selectedGooseSourceKeys;
+ public int GooseSelectedSourceCount => _selectedGooseSourceKeys.Count;
+ public bool GooseSourceFilterActive => _selectedGooseSourceKeys.Count > 0;
+ public string GooseSourceScopeText => GooseSourceFilterActive
+ ? $"{_selectedGooseSourceKeys.Count} selected" : "All sources";
+ public bool FollowGooseMessages
+ {
+ get => _followGooseMessages;
+ set
+ {
+ if (_followGooseMessages == value) return;
+ _followGooseMessages = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(GooseFollowSuspended));
+ }
+ }
+ public bool GooseFollowSuspended => !_followGooseMessages;
+
+ public void SetGooseSourceSelection(IEnumerable keys)
+ {
+ var next = keys.Take(256).ToHashSet();
+ if (_selectedGooseSourceKeys.SetEquals(next)) return;
+ _selectedGooseSourceKeys.Clear();
+ _selectedGooseSourceKeys.UnionWith(next);
+ OnPropertyChanged(nameof(GooseSelectedSourceCount));
+ OnPropertyChanged(nameof(GooseSourceFilterActive));
+ OnPropertyChanged(nameof(GooseSourceScopeText));
+ OnPropertyChanged(nameof(GoosePublisherCountText));
+ OnPropertyChanged(nameof(GooseEmptyGuidance));
+ _gooseHistoryView.Refresh(); // operator action only; never packet-driven
+ UpdateGooseVisibleEventCount();
+ }
+ public void ClearGooseSourceSelection() => SetGooseSourceSelection(Array.Empty());
// DataSet row instances survive new st/sq snapshots, preserving expanded
// MMS/quality details while live retransmissions continue.
public ObservableCollection GooseInspectorRows => _gooseInspectorRows;
@@ -247,10 +284,7 @@ public string GoosePublisherFilterText
OnPropertyChanged(nameof(GooseHasSearchText));
OnPropertyChanged(nameof(GooseFilterSummaryText));
OnPropertyChanged(nameof(GooseEmptyGuidance));
- _gooseHistoryView.Refresh();
- UpdateGooseVisibleEventCount();
_goosePublisherRowsView.Refresh();
- OnPropertyChanged(nameof(SelectedGoosePublisherRow));
}
}
@@ -328,10 +362,7 @@ public string SelectedGooseIdFilter
OnPropertyChanged();
OnPropertyChanged(nameof(GooseFilterSummaryText));
OnPropertyChanged(nameof(GooseEmptyGuidance));
- _gooseHistoryView.Refresh();
- UpdateGooseVisibleEventCount();
- _goosePublisherRowsView.Refresh();
- OnPropertyChanged(nameof(SelectedGoosePublisherRow));
+ // Legacy compatibility property is no longer an invisible timeline filter.
}
}
@@ -340,13 +371,13 @@ public string SelectedGooseIdFilter
public string GooseEmptyGuidance => _gooseMessages.Count == 0
? "Check the selected network adapter, incoming traffic, and network group settings."
: IncludeGooseRetransmission
- ? "Check the message-name filter and search text."
+ ? "Check the selected sources or clear the filter."
: "No new state changes are shown. Turn on repeated messages to see ongoing traffic.";
public string GooseRetransmissionModeText => IncludeGooseRetransmission
? "Repeated messages visible" : "Showing changes only";
- public string GoosePublisherCountText => $"{_gooseMessages.Count} device{(_gooseMessages.Count == 1 ? "" : "s")}";
+ public string GoosePublisherCountText => $"{_gooseMessages.Count} sources · {GooseSourceScopeText}";
public string GooseTimelineCountersText =>
- $"{_gooseSnapshot.TotalMessages:N0} messages received · {_gooseVisibleEventCount:N0} shown";
+ $"{_gooseSnapshot.TotalMessages:N0} received · {_gooseVisibleEventCount:N0} shown";
public string GooseWorkspaceCaptureSummaryText
{
get
@@ -846,6 +877,8 @@ public MainWindowViewModel()
});
GooseInteractionCommand = new RelayCommand(() => DeferGooseUiFlush());
GooseReturnToLiveCommand = new RelayCommand(ReturnGooseToLive);
+ GooseClearSourceSelectionCommand = new RelayCommand(ClearGooseSourceSelection);
+ GooseFollowLiveCommand = new RelayCommand(() => FollowGooseMessages = true);
GooseOpenConfigurationCommand = new RelayCommand(() => CurrentWorkspaceTabIndex = 5);
GooseClearSearchCommand = new RelayCommand(() => GoosePublisherFilterText = string.Empty);
@@ -1267,6 +1300,8 @@ private void ApplySelectedStreamScale(string? streamId)
public ICommand SwitchToRawCommand { get; }
public ICommand GooseInteractionCommand { get; }
public ICommand GooseReturnToLiveCommand { get; }
+ public ICommand GooseClearSourceSelectionCommand { get; }
+ public ICommand GooseFollowLiveCommand { get; }
public ICommand GooseOpenConfigurationCommand { get; }
public ICommand GooseClearSearchCommand { get; }
public ICommand CopyEvidenceCommand { get; }
@@ -2343,7 +2378,7 @@ private async Task RefreshAsync()
_gooseSnapshot = gooseSnapshot;
_gooseMessages = _gooseSnapshot.Messages;
- _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey);
+ _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey, _selectedGooseSourceKeys);
UpdateGooseIdFilterOptions();
foreach (var msg in _gooseSnapshot.Messages)
@@ -4514,7 +4549,7 @@ private async Task StopAsync()
// Stop keeps the last selected publisher as STALE; Clear removes it.
_gooseMessages = Array.Empty();
- _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey);
+ _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey, _selectedGooseSourceKeys);
_gooseSnapshot = new GooseMonitorSnapshot();
UpdateGooseIdFilterOptions();
RaiseGooseInspectorProperties();
@@ -4536,6 +4571,8 @@ private async Task ClearAsync()
_gooseSnapshot = new GooseMonitorSnapshot { StatusText = "Cleared" };
_gooseMessages = Array.Empty();
_gooseCatalog.Clear();
+ ClearGooseSourceSelection();
+ FollowGooseMessages = true;
_selectedGoosePublisherKey = null;
_gooseInspectHistoricalEvent = false;
OnPropertyChanged(nameof(IsGooseHistoryPinned));
@@ -4629,42 +4666,17 @@ private static PhasorDisplayItem CreatePhasor(string name, PhasorFamily family,
private bool FilterGoosePublisher(object item) =>
item is GoosePublisherRow row &&
- GoosePublisherCatalog.MatchesFilter(row.Current, SelectedGooseIdFilter, GoosePublisherFilterText);
+ GoosePublisherCatalog.MatchesFilter(row.Current, "All", GoosePublisherFilterText);
private bool FilterGooseHistory(object item)
{
- if (item is not GooseTrafficRow row)
- return false;
-
- if (!IncludeGooseRetransmission && string.Equals(row.EventType, "Retrans", StringComparison.OrdinalIgnoreCase))
+ if (item is not GooseTrafficRow row) return false;
+ if (!IncludeGooseRetransmission &&
+ string.Equals(row.EventType, "Retrans", StringComparison.OrdinalIgnoreCase))
return false;
-
- if (!string.Equals(SelectedGooseIdFilter, "All", StringComparison.OrdinalIgnoreCase))
- {
- var matchesSelected = string.Equals(row.GoId, SelectedGooseIdFilter, StringComparison.OrdinalIgnoreCase) ||
- string.Equals(row.GoCbRef, SelectedGooseIdFilter, StringComparison.OrdinalIgnoreCase);
- if (!matchesSelected)
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(GoosePublisherFilterText))
- {
- var filter = GoosePublisherFilterText.Trim();
- var matchesText = Contains(row.GoId, filter) ||
- Contains(row.GoCbRef, filter) ||
- Contains(row.AppId, filter) ||
- Contains(row.DataSet, filter) ||
- Contains(row.SourceMac, filter) ||
- Contains(row.DestinationMac, filter);
- if (!matchesText)
- return false;
- }
-
- return true;
-
- static bool Contains(string? text, string filter) =>
- !string.IsNullOrWhiteSpace(text) &&
- text.Contains(filter, StringComparison.OrdinalIgnoreCase);
+ // No search/GoID coupling: only the union of stable physical source keys.
+ return _selectedGooseSourceKeys.Count == 0 ||
+ GoosePublisherCatalog.MatchesSelection(row.Source, _selectedGooseSourceKeys);
}
private IReadOnlyList BuildGooseDatasetValues(GooseMessageItem? goose)
@@ -4885,9 +4897,15 @@ private void FlushBufferedGooseRows(bool flushAll = false)
var limit = flushAll ? int.MaxValue : GooseBufferedFlushLimit;
var flushed = 0;
+ GooseTrafficRow? newestVisible = null;
while (_pendingGooseRows.Count > 0 && flushed < limit)
{
- _gooseHistory.Add(_pendingGooseRows.Dequeue());
+ var row = _pendingGooseRows.Dequeue();
+ if (FilterGooseHistory(row))
+ row.VisibleSinceUtc = DateTime.UtcNow;
+ _gooseHistory.Add(row);
+ if (FilterGooseHistory(row))
+ newestVisible = row;
flushed++;
}
@@ -4902,6 +4920,9 @@ private void FlushBufferedGooseRows(bool flushAll = false)
// The view observes incremental changes. Never Refresh() on each event.
if (flushed > 0 || removeCount > 0)
UpdateGooseVisibleEventCount();
+ // Signal once per completed UI batch, never per captured packet.
+ if (newestVisible is not null)
+ GooseVisibleMessagesAppended?.Invoke(newestVisible);
}
private void TryFlushBufferedGooseRows()
@@ -5013,6 +5034,8 @@ private static Brush FrozenBrush(byte red, byte green, byte blue)
public sealed class GooseTrafficRow
{
+ // Only the first live realization after an actual UI-batch append may animate.
+ public DateTime VisibleSinceUtc { get; set; }
public required GooseMessageItem Source { get; init; }
public string EventType { get; init; } = "STATE";
diff --git a/src/ProcessBus.Core/Models/GooseMessageItem.cs b/src/ProcessBus.Core/Models/GooseMessageItem.cs
index 1271ad77..8f25e1db 100644
--- a/src/ProcessBus.Core/Models/GooseMessageItem.cs
+++ b/src/ProcessBus.Core/Models/GooseMessageItem.cs
@@ -20,6 +20,9 @@ public sealed class GooseMessageItem
public DateTime LastSeenUtc { get; set; }
public string ValuesText { get; set; } = "N/A";
public string ChangedSummaryText { get; set; } = "N/A";
+ // Decoder-owned revision survives retransmissions between UI snapshots.
+ public long ValueChangeRevision { get; set; }
+ public DateTime? LastValueChangeUtc { get; set; }
public string StatusText { get; set; } = "Detected";
public IReadOnlyList DataValues { get; set; } = Array.Empty();
}
diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs
index 7aa91f4e..0a93a73b 100644
--- a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs
+++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs
@@ -45,6 +45,18 @@ public GoosePublisherRow(GoosePublisherKey key, GooseMessageItem current)
public bool IsTest => Current.IsTest;
public uint StNum => Current.StNum;
public uint SqNum => Current.SqNum;
+ public bool HasRecentValueChange { get; private set; }
+ public string LastValueChangeText => Current.LastValueChangeUtc.HasValue
+ ? $"DataSet changed {Current.LastValueChangeUtc.Value.ToLocalTime():HH:mm:ss.fff}" : string.Empty;
+ public void RefreshChangeIndicator(DateTime nowUtc)
+ {
+ var active = Current.ValueChangeRevision > 0 && Current.LastValueChangeUtc.HasValue &&
+ nowUtc >= Current.LastValueChangeUtc.Value &&
+ nowUtc - Current.LastValueChangeUtc.Value < TimeSpan.FromSeconds(3);
+ if (active == HasRecentValueChange) return;
+ HasRecentValueChange = active;
+ OnPropertyChanged(nameof(HasRecentValueChange));
+ }
public event PropertyChangedEventHandler? PropertyChanged;
@@ -70,6 +82,9 @@ public bool Update(GooseMessageItem message)
if (before.IsTest != message.IsTest) OnPropertyChanged(nameof(IsTest));
if (before.StNum != message.StNum) OnPropertyChanged(nameof(StNum));
if (before.SqNum != message.SqNum) OnPropertyChanged(nameof(SqNum));
+ if (before.ValueChangeRevision != message.ValueChangeRevision)
+ OnPropertyChanged(nameof(LastValueChangeText));
+ RefreshChangeIndicator(DateTime.UtcNow);
return true;
}
@@ -98,8 +113,10 @@ public sealed class GoosePublisherCatalog
public GoosePublisherRow? Find(GoosePublisherKey? key) =>
key.HasValue && _byKey.TryGetValue(key.Value, out var row) ? row : null;
- public bool Reconcile(IReadOnlyList messages, GoosePublisherKey? selected)
+ public bool Reconcile(IReadOnlyList messages, GoosePublisherKey? selected,
+ IReadOnlySet? selectedSources = null)
{
+ var nowUtc = DateTime.UtcNow;
var seen = new HashSet();
var changed = false;
foreach (var message in messages)
@@ -124,18 +141,27 @@ public bool Reconcile(IReadOnlyList messages, GoosePublisherKe
{
var row = Rows[i];
if (seen.Contains(row.Key)) continue;
- if (selected.HasValue && selected.Value == row.Key)
+ if ((selected.HasValue && selected.Value == row.Key) ||
+ (selectedSources?.Contains(row.Key) ?? false))
{
row.MarkStale();
+ row.RefreshChangeIndicator(nowUtc);
continue;
}
_byKey.Remove(row.Key);
Rows.RemoveAt(i);
changed = true;
}
+ foreach (var row in Rows)
+ row.RefreshChangeIndicator(nowUtc);
return changed;
}
+ /// Matches a union of physical publisher identities; empty means all.
+ public static bool MatchesSelection(GooseMessageItem message,
+ IReadOnlySet keys) =>
+ keys.Count == 0 || keys.Contains(GoosePublisherKey.From(message));
+
/// Same deterministic filter for publisher exploration; filtering
/// never changes the operator's selected publisher key.
public static bool MatchesFilter(GooseMessageItem message, string? gooseId, string? search)
diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs
index 6129f936..d390b32e 100644
--- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs
+++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs
@@ -501,6 +501,9 @@ private void ObserveGoose(GoosePacket packet)
var previous = state.Item;
AnnotateGooseChanges(item, previous);
+ var valueChanged = item.DataValues.Any(value => value.IsChanged);
+ item.ValueChangeRevision = previous.ValueChangeRevision + (valueChanged ? 1 : 0);
+ item.LastValueChangeUtc = valueChanged ? item.LastSeenUtc : previous.LastValueChangeUtc;
if (previous.StNum != item.StNum || previous.SqNum != item.SqNum)
{
@@ -760,7 +763,12 @@ private static void AnnotateGooseChanges(GooseMessageItem current, GooseMessageI
return;
}
- if (previous is null || previous.DataValues.Count == 0)
+ if (previous is null || previous.DataValues.Count == 0 ||
+ current.ConfRev != previous.ConfRev ||
+ !string.Equals(current.DataSet, previous.DataSet, StringComparison.Ordinal) ||
+ current.DataValues.Count != previous.DataValues.Count ||
+ current.DataValues.Any(value => !previous.DataValues.Any(old =>
+ old.Index == value.Index && string.Equals(old.Type, value.Type, StringComparison.Ordinal))))
{
// The first observation has no comparative baseline. Calling every
// value "changed" fabricates a state transition not seen on the wire.
@@ -2891,6 +2899,8 @@ public GooseMessageItem Clone()
LastSeenUtc = Item.LastSeenUtc,
ValuesText = Item.ValuesText,
ChangedSummaryText = Item.ChangedSummaryText,
+ ValueChangeRevision = Item.ValueChangeRevision,
+ LastValueChangeUtc = Item.LastValueChangeUtc,
StatusText = Item.StatusText,
DataValues = Item.DataValues
.Select(x => new GooseDatasetValueItem
diff --git a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs
index 4b4e3366..63eaaedb 100644
--- a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs
+++ b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs
@@ -163,6 +163,68 @@ public void TestFlagUpdatesWithoutRecreatingTheSelectedPublisherRow()
Assert.Contains(nameof(GoosePublisherRow.IsTest), changed);
}
+ [Fact]
+ public void SourceScopeUsesPhysicalKeysWithZeroOneAndManySelected()
+ {
+ var first = Frame(source: "02:00:4C:4E:4E:50");
+ var second = Frame(source: "02:00:4C:4E:4E:51");
+ var third = Frame(source: "02:00:4C:4E:4E:52");
+ var keys = new HashSet();
+ Assert.True(GoosePublisherCatalog.MatchesSelection(first, keys));
+ Assert.True(GoosePublisherCatalog.MatchesSelection(second, keys));
+ keys.Add(GoosePublisherKey.From(first));
+ Assert.True(GoosePublisherCatalog.MatchesSelection(first, keys));
+ Assert.False(GoosePublisherCatalog.MatchesSelection(second, keys));
+ keys.Add(GoosePublisherKey.From(second));
+ Assert.True(GoosePublisherCatalog.MatchesSelection(second, keys));
+ Assert.False(GoosePublisherCatalog.MatchesSelection(third, keys));
+ keys.Clear();
+ Assert.True(GoosePublisherCatalog.MatchesSelection(third, keys));
+ }
+
+ [Fact]
+ public void MultipleSelectedPublishersSurviveStaleAndReappearance()
+ {
+ var catalog = new GoosePublisherCatalog();
+ var a = Frame();
+ var b = Frame(source: "02:00:4C:4E:4E:51");
+ var keys = new HashSet { GoosePublisherKey.From(a), GoosePublisherKey.From(b) };
+ catalog.Reconcile([a, b], null, keys);
+ var originalA = catalog.Find(GoosePublisherKey.From(a));
+ var originalB = catalog.Find(GoosePublisherKey.From(b));
+ catalog.Reconcile([], null, keys);
+ Assert.True(originalA!.IsStale);
+ Assert.True(originalB!.IsStale);
+ catalog.Reconcile([Frame(sq: 99)], null, keys);
+ Assert.Same(originalA, catalog.Find(GoosePublisherKey.From(a)));
+ Assert.Same(originalB, catalog.Find(GoosePublisherKey.From(b)));
+ Assert.False(originalA!.IsStale);
+ Assert.True(originalB!.IsStale);
+ }
+
+ [Fact]
+ public void ChangedNoticeNeedsPositiveDecoderRevisionAndExpires()
+ {
+ var catalog = new GoosePublisherCatalog();
+ var original = Frame();
+ catalog.Reconcile([original], null);
+ var row = Assert.Single(catalog.Rows);
+ Assert.False(row.HasRecentValueChange); // baseline
+ var repeat = Frame(sq: 2);
+ catalog.Reconcile([repeat], null);
+ Assert.False(row.HasRecentValueChange); // sqNum never proves changed value
+ var change = Frame(st: 2);
+ change.ValueChangeRevision = 1;
+ change.LastValueChangeUtc = DateTime.UtcNow;
+ catalog.Reconcile([change], null);
+ Assert.True(row.HasRecentValueChange);
+ var expired = Frame(sq: 5);
+ expired.ValueChangeRevision = 1;
+ expired.LastValueChangeUtc = DateTime.UtcNow.AddSeconds(-10);
+ catalog.Reconcile([expired], null);
+ Assert.False(row.HasRecentValueChange);
+ }
+
[Fact]
public void HighTrafficDoesNotCauseUnboundedRows()
{