From 87c619f2695de2fb3b5712d2975a4b3b37fd1e04 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:23:04 +0700 Subject: [PATCH 001/102] test(goose): introduce stable keyed publisher reconciliation and deterministic burst tests --- .../Presentation/GoosePublisherCatalog.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs new file mode 100644 index 00000000..95a12a81 --- /dev/null +++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs @@ -0,0 +1,140 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using ProcessBus.Core.Models; + +namespace ProcessBus.Core.Presentation; + +/// +/// Protocol identity of a GOOSE control block on a particular captured LAN. +/// Sequence numbers, reception time, ConfRev and DataSet values are NOT identity. +/// GoCB references are case-sensitive IEC 61850 object references. +/// +public readonly record struct GoosePublisherKey(string SourceMac, string AppId, string GoCbRef, string VlanId) +{ + public static GoosePublisherKey From(GooseMessageItem message) => new( + NormalizeMac(message.SourceMac), + (message.AppId ?? string.Empty).Trim().ToUpperInvariant(), + (message.GoCbRef ?? string.Empty).Trim(), + (message.VlanId ?? string.Empty).Trim()); + + private static string NormalizeMac(string? mac) => + (mac ?? string.Empty).Trim().Replace('-', ':').ToUpperInvariant(); +} + +/// +/// Stable WPF item container identity. The receive snapshot is replaced atomically, +/// while the row instance is preserved; only changed visible fields are notified. +/// +public sealed class GoosePublisherRow : INotifyPropertyChanged +{ + public GoosePublisherRow(GoosePublisherKey key, GooseMessageItem current) + { + Key = key; + Current = current; + } + + public GoosePublisherKey Key { get; } + public GooseMessageItem Current { get; private set; } + public bool IsStale { get; private set; } + public string GoId => Current.GoId; + public string GoCbRef => Current.GoCbRef; + public string AppId => Current.AppId; + public uint StNum => Current.StNum; + public uint SqNum => Current.SqNum; + + public event PropertyChangedEventHandler? PropertyChanged; + + public bool Update(GooseMessageItem message) + { + if (Key != GoosePublisherKey.From(message)) + throw new ArgumentException("Publisher identity mismatch.", nameof(message)); + + var before = Current; + var changed = !ReferenceEquals(before, message); + Current = message; + if (IsStale) + { + IsStale = false; + OnPropertyChanged(nameof(IsStale)); + } + if (!changed) + return false; + + if (before.GoId != message.GoId) OnPropertyChanged(nameof(GoId)); + if (before.GoCbRef != message.GoCbRef) OnPropertyChanged(nameof(GoCbRef)); + if (before.AppId != message.AppId) OnPropertyChanged(nameof(AppId)); + if (before.StNum != message.StNum) OnPropertyChanged(nameof(StNum)); + if (before.SqNum != message.SqNum) OnPropertyChanged(nameof(SqNum)); + return true; + } + + internal void MarkStale() + { + if (IsStale) return; + IsStale = true; + OnPropertyChanged(nameof(IsStale)); + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} + +/// +/// Bounded keyed reconciliation on the UI dispatcher, never on the capture hot path. +/// An absent selected publisher retains its last coherent frame as stale; unrelated +/// evicted publishers are removed without resetting the selected item container. +/// +public sealed class GoosePublisherCatalog +{ + private const int MaxPublisherRows = 256; + private readonly Dictionary _byKey = new(); + public ObservableCollection Rows { get; } = new(); + + public GoosePublisherRow? Find(GoosePublisherKey? key) => + key.HasValue && _byKey.TryGetValue(key.Value, out var row) ? row : null; + + public bool Reconcile(IReadOnlyList messages, GoosePublisherKey? selected) + { + var seen = new HashSet(); + var changed = false; + foreach (var message in messages) + { + var key = GoosePublisherKey.From(message); + seen.Add(key); + if (_byKey.TryGetValue(key, out var row)) + { + changed |= row.Update(message); + } + else if (Rows.Count < MaxPublisherRows) + { + row = new GoosePublisherRow(key, message); + _byKey.Add(key, row); + Rows.Add(row); + changed = true; + } + } + + // Only remove truly absent non-selected rows. Do not rebuild the whole list. + for (var i = Rows.Count - 1; i >= 0; i--) + { + var row = Rows[i]; + if (seen.Contains(row.Key)) continue; + if (selected.HasValue && selected.Value == row.Key) + { + row.MarkStale(); + continue; + } + _byKey.Remove(row.Key); + Rows.RemoveAt(i); + changed = true; + } + return changed; + } + + public void Clear() + { + _byKey.Clear(); + Rows.Clear(); + } +} From db1c6096fad30bbc80a4307edb7ab6f601434f43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:23:07 +0700 Subject: [PATCH 002/102] test(goose): introduce stable keyed publisher reconciliation and deterministic burst tests --- .../GoosePublisherSelectionTests.cs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs diff --git a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs new file mode 100644 index 00000000..f8625faa --- /dev/null +++ b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs @@ -0,0 +1,114 @@ +using ProcessBus.Core.Models; +using ProcessBus.Core.Presentation; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class GoosePublisherSelectionTests +{ + private static GooseMessageItem Frame( + string source = "02:00:4C:4E:4E:50", + string goCb = "IED/LLN0$GO$gcb", + uint st = 1, uint sq = 1, string vlan = "0") => new() + { + MessageId = $"{source}|{goCb}", + SourceMac = source, + GoCbRef = goCb, + GoId = "SharedGoID", + AppId = "0x0001", + VlanId = vlan, + StNum = st, + SqNum = sq, + DataValues = [new GooseDatasetValueItem { Index = 1, Name = "stVal", Type = "Boolean", Value = st.ToString() }], + LastSeenUtc = new DateTime(2026, 10, 21, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(sq) + }; + + [Fact] + public void RetransmissionsKeepSameRowAndExposeLatestTypedFrame() + { + var catalog = new GoosePublisherCatalog(); + var first = Frame(); + var key = GoosePublisherKey.From(first); + catalog.Reconcile([first], null); + var selected = Assert.Single(catalog.Rows); + var resets = 0; + catalog.Rows.CollectionChanged += (_, _) => resets++; + + for (uint sq = 2; sq <= 1000; sq++) + catalog.Reconcile([Frame(sq: sq)], key); + + Assert.Same(selected, catalog.Find(key)); + Assert.Equal((uint)1000, selected.Current.SqNum); + Assert.Single(selected.Current.DataValues); + Assert.Equal(0, resets); + Assert.False(selected.IsStale); + } + + [Fact] + public void SameGoIdAcrossMacAndVlanNeverCoalesces() + { + var catalog = new GoosePublisherCatalog(); + catalog.Reconcile([Frame(), Frame(source: "02:00:4C:4E:4E:51"), Frame(vlan: "7")], null); + Assert.Equal(3, catalog.Rows.Count); + } + + [Fact] + public void SelectedPublisherSurvivesAlternatingTrafficAndTemporaryAbsence() + { + var catalog = new GoosePublisherCatalog(); + var a = Frame(); + var b = Frame(source: "02:00:4C:4E:4E:51"); + var key = GoosePublisherKey.From(a); + catalog.Reconcile([a,b], null); + var selected = catalog.Find(key); + catalog.Reconcile([Frame(source: b.SourceMac, sq: 10)], key); + Assert.Same(selected, catalog.Find(key)); + Assert.True(selected!.IsStale); + Assert.Single(catalog.Rows); + catalog.Reconcile([Frame(sq: 30), Frame(source: b.SourceMac, sq: 11)], key); + Assert.Same(selected, catalog.Find(key)); + Assert.False(selected!.IsStale); + Assert.Equal((uint)30, selected.Current.SqNum); + } + + [Fact] + public void StateNumbersAndDataSetDoNotBecomePublisherIdentity() + { + var catalog = new GoosePublisherCatalog(); + var a = Frame(st: 1, sq: 2); + var k = GoosePublisherKey.From(a); + catalog.Reconcile([a], null); + var same = catalog.Find(k); + catalog.Reconcile([Frame(st: 15, sq: 5)], k); + Assert.Same(same, catalog.Find(k)); + Assert.Equal((uint)15, same!.Current.StNum); + Assert.Equal("15", same.Current.DataValues[0].Value); + } + + [Fact] + public void ExplicitClearIsDifferentFromTransientEmptySnapshot() + { + var catalog = new GoosePublisherCatalog(); + var a = Frame(); + var key = GoosePublisherKey.From(a); + catalog.Reconcile([a], key); + catalog.Reconcile([], key); + Assert.True(catalog.Find(key)!.IsStale); + catalog.Clear(); + Assert.Empty(catalog.Rows); + Assert.Null(catalog.Find(key)); + } + + [Fact] + public void HighTrafficDoesNotCauseUnboundedRows() + { + var catalog = new GoosePublisherCatalog(); + var frames = Enumerable.Range(0, 500) + .Select(i => Frame(source: $"02:00:00:{i / 65536:X2}:{i / 256 % 256:X2}:{i % 256:X2}")) + .ToArray(); + catalog.Reconcile(frames, null); + Assert.InRange(catalog.Rows.Count, 1, 256); + catalog.Reconcile([], null); + Assert.Empty(catalog.Rows); + } +} From 6d5244f514cb0a0e59373ba60115b35d7d861cb4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:24:45 +0700 Subject: [PATCH 003/102] fix(goose): preserve keyed publisher selection and independent coherent inspector mode --- .../ViewModels/MainWindowViewModel.cs | 169 ++++++++++++------ 1 file changed, 114 insertions(+), 55 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 24a460a1..9d996938 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -44,7 +44,11 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private string _selectedAdapterId = string.Empty; private SvStreamItem? _selectedStream; private GooseMessageItem? _selectedGooseMessage; + private readonly GoosePublisherCatalog _gooseCatalog = new(); + private GoosePublisherKey? _selectedGoosePublisherKey; private GooseTrafficRow? _selectedGooseTrafficRow; + private bool _gooseInspectHistoricalEvent; + private IReadOnlyList _gooseIdFilterOptions = ["All"]; private IReadOnlyList _gooseMessages = Array.Empty(); private GooseMonitorSnapshot _gooseSnapshot = new(); private IReadOnlyList _phasors = Array.Empty(); @@ -123,7 +127,7 @@ private set ? invalid.Description : "Select a real Npcap capture adapter."; private readonly ObservableCollection _gooseHistory = new(); - private readonly Dictionary _lastGooseState = new(); + private readonly Dictionary _lastGooseState = new(); private readonly Queue _pendingGooseRows = new(); private readonly Queue _pendingDiagnosticEvents = new(); private readonly ObservableCollection _calmDiagnosticEvents = new(); @@ -233,20 +237,22 @@ public string GoosePublisherFilterText } } - public IReadOnlyList GooseIdFilterOptions - { - get - { - var values = _gooseMessages - .Select(m => string.IsNullOrWhiteSpace(m.GoId) ? m.GoCbRef : m.GoId) - .Where(v => !string.IsNullOrWhiteSpace(v) && !string.Equals(v, "N/A", StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) - .ToList(); + // Replace ComboBox options only if identities change, not with each UI tick. + public IReadOnlyList GooseIdFilterOptions => _gooseIdFilterOptions; - values.Insert(0, "All"); - return values; - } + private void UpdateGooseIdFilterOptions() + { + var values = _gooseMessages + .Select(m => string.IsNullOrWhiteSpace(m.GoId) ? m.GoCbRef : m.GoId) + .Where(v => !string.IsNullOrWhiteSpace(v) && !string.Equals(v, "N/A", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) + .Prepend("All") + .ToArray(); + if (_gooseIdFilterOptions.SequenceEqual(values, StringComparer.Ordinal)) + return; + _gooseIdFilterOptions = values; + OnPropertyChanged(nameof(GooseIdFilterOptions)); } public string SelectedGooseIdFilter @@ -254,7 +260,11 @@ public string SelectedGooseIdFilter get => _selectedGooseIdFilter; set { - var next = string.IsNullOrWhiteSpace(value) ? "All" : value; + // WPF can write null while replacing a ComboBox ItemsSource. + // Clear is the explicit "All" option, never a transient null. + if (string.IsNullOrWhiteSpace(value)) + return; + var next = value; if (string.Equals(_selectedGooseIdFilter, next, StringComparison.Ordinal)) return; @@ -286,16 +296,17 @@ public string GooseFilterSummaryText } public IReadOnlyList SelectedGooseDatasetValues => - BuildGooseDatasetValues(SelectedGooseMessage); + BuildGooseDatasetValues(GooseInspectorMessage); public string SelectedGooseSemanticText { get { - if (SelectedGooseMessage is null) - return "Select a GOOSE publisher to inspect semantic DataSet values."; + var inspected = GooseInspectorMessage; + if (inspected is null) + return "Select a GOOSE publisher or timeline event to inspect typed DataSet values."; - var match = ResolveSclGooseStream(SelectedGooseMessage); + var match = ResolveSclGooseStream(inspected); if (match is null) return HasSclProject ? "Semantic source: generic typed decode; no matching SCL GOOSE DataSet found." @@ -817,46 +828,98 @@ public SvStreamItem? SelectedStream } } + // Stable ListView containers: snapshot messages never own WPF selection. public IReadOnlyList GooseMessages => _gooseMessages; + public ObservableCollection GoosePublisherRows => _gooseCatalog.Rows; - public GooseMessageItem? SelectedGooseMessage + public GoosePublisherRow? SelectedGoosePublisherRow { - get => _selectedGooseMessage; + get => _gooseCatalog.Find(_selectedGoosePublisherKey); set { - if (ReferenceEquals(_selectedGooseMessage, value)) + // Ignore WPF's transient null during view/container recycling. + // ClearAsync is the explicit reset path. + if (value is null) + return; + if (value.Key == _selectedGoosePublisherKey && !_gooseInspectHistoricalEvent) return; + _selectedGoosePublisherKey = value.Key; + _selectedGooseMessage = value.Current; + _gooseInspectHistoricalEvent = false; BeginWorkspaceTargetTransition(); - _selectedGooseMessage = value; OnPropertyChanged(); - OnPropertyChanged(nameof(SelectedGooseDatasetValues)); - OnPropertyChanged(nameof(SelectedGooseSemanticText)); + RaiseGooseInspectorProperties(); RaiseDiagnosticScopeProperties(); RaiseAdvancedProperties(); } } + public GooseMessageItem? SelectedGooseMessage + { + get => _selectedGooseMessage; + set + { + if (value is null) + return; + var row = _gooseCatalog.Find(GoosePublisherKey.From(value)); + if (row is not null) + SelectedGoosePublisherRow = row; + } + } + + // Independent user intent: selecting a timeline event pins its historical + // coherent frame; clicking a publisher returns the inspector to live mode. public GooseTrafficRow? SelectedGooseTrafficRow { get => _selectedGooseTrafficRow; set { - if (ReferenceEquals(_selectedGooseTrafficRow, value)) + if (value is null || ReferenceEquals(_selectedGooseTrafficRow, value)) return; - - BeginWorkspaceTargetTransition(); _selectedGooseTrafficRow = value; - _selectedGooseMessage = value?.Source; - + _gooseInspectHistoricalEvent = true; + BeginWorkspaceTargetTransition(); OnPropertyChanged(); - OnPropertyChanged(nameof(SelectedGooseMessage)); - OnPropertyChanged(nameof(SelectedGooseDatasetValues)); - OnPropertyChanged(nameof(SelectedGooseSemanticText)); + RaiseGooseInspectorProperties(); RaiseAdvancedProperties(); } } + public GooseMessageItem? GooseInspectorMessage => + _gooseInspectHistoricalEvent ? _selectedGooseTrafficRow?.Source : _selectedGooseMessage; + + public string GooseInspectorDatasetCountText => + (GooseInspectorMessage?.DataValues.Count ?? 0).ToString(); + + public string GooseInspectorModeText + { + get + { + var item = GooseInspectorMessage; + if (item is null) + return "Select a publisher for live values or an event for historical evidence."; + if (_gooseInspectHistoricalEvent) + return $"Pinned event · {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · historical values"; + var ttl = Math.Max(1000.0, item.TimeAllowedToLiveMilliseconds); + var stale = SelectedGoosePublisherRow?.IsStale == true || + DateTime.UtcNow - item.LastSeenUtc > TimeSpan.FromMilliseconds(ttl); + return stale + ? $"STALE · last received {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · last valid values retained" + : "Live publisher · latest received typed DataSet values"; + } + } + + private void RaiseGooseInspectorProperties() + { + OnPropertyChanged(nameof(SelectedGooseMessage)); + OnPropertyChanged(nameof(GooseInspectorMessage)); + OnPropertyChanged(nameof(GooseInspectorDatasetCountText)); + OnPropertyChanged(nameof(GooseInspectorModeText)); + OnPropertyChanged(nameof(SelectedGooseDatasetValues)); + OnPropertyChanged(nameof(SelectedGooseSemanticText)); + } + public GooseMonitorSnapshot GooseSnapshot => _gooseSnapshot; public ProtocolMonitorSnapshot ProtocolMonitor => _state.ProtocolMonitor; public ObservableCollection PtpEvents => _state.PtpEvents; @@ -895,8 +958,6 @@ public int CurrentWorkspaceTabIndex if (IsGooseTabActive) { FlushBufferedGooseRows(); - if (SelectedGooseTrafficRow is null && _gooseHistory.Count > 0) - SelectedGooseTrafficRow = _gooseHistory[^1]; } if (IsDebugTabActive) UpdateDebugDisplaySnapshot(); @@ -2086,15 +2147,13 @@ private async Task RefreshAsync() _gooseSnapshot = gooseSnapshot; _gooseMessages = _gooseSnapshot.Messages; - if (_selectedGooseMessage is not null) - { - _selectedGooseMessage = _gooseMessages.FirstOrDefault(x => string.Equals(x.MessageId, _selectedGooseMessage.MessageId, StringComparison.OrdinalIgnoreCase)) - ?? _selectedGooseMessage; - } + _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey); + _selectedGooseMessage = SelectedGoosePublisherRow?.Current; + UpdateGooseIdFilterOptions(); foreach (var msg in _gooseSnapshot.Messages) { - var key = $"{msg.GoId}|{msg.GoCbRef}|{msg.DataSet}"; + var key = GoosePublisherKey.From(msg); if (!_lastGooseState.TryGetValue(key, out var last)) { @@ -2166,11 +2225,6 @@ private async Task RefreshAsync() SynchronizeSelectedStreamRow(); var selectedStreamId = _selectedStream?.StreamId; - if (IsGooseTabActive && SelectedGooseTrafficRow is null && _gooseHistory.Count > 0) - { - SelectedGooseTrafficRow = _gooseHistory[^1]; - } - if (IsAnalyzerTabActive) { // ARSVIN-style contract: render the actual runtime stream snapshot that the @@ -2755,18 +2809,15 @@ private void RaiseDiagnosticsProperties() private void RaiseGooseProperties() { OnPropertyChanged(nameof(GooseSnapshot)); - OnPropertyChanged(nameof(GooseMessages)); OnPropertyChanged(nameof(GooseStatusText)); OnPropertyChanged(nameof(GooseTotalMessagesText)); OnPropertyChanged(nameof(GooseDetectedCountText)); OnPropertyChanged(nameof(GoosePublisherCountText)); - OnPropertyChanged(nameof(GooseIdFilterOptions)); OnPropertyChanged(nameof(GooseFilterSummaryText)); OnPropertyChanged(nameof(GooseEmptyTitle)); OnPropertyChanged(nameof(GooseEmptyGuidance)); - OnPropertyChanged(nameof(SelectedGooseDatasetValues)); - OnPropertyChanged(nameof(SelectedGooseSemanticText)); - _gooseHistoryView.Refresh(); + if (!_gooseInspectHistoricalEvent && _selectedGoosePublisherKey.HasValue) + RaiseGooseInspectorProperties(); } private void RaiseTimingProperties() @@ -4262,8 +4313,12 @@ private async Task StopAsync() s.StatusSoftBrush = "#1C2A38"; } + // Stop keeps the last selected publisher as STALE; Clear removes it. _gooseMessages = Array.Empty(); + _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey); _gooseSnapshot = new GooseMonitorSnapshot(); + UpdateGooseIdFilterOptions(); + RaiseGooseInspectorProperties(); OnPropertyChanged(nameof(Streams)); OnPropertyChanged(nameof(SelectedStream)); @@ -4281,6 +4336,10 @@ private async Task ClearAsync() _state.ClearRuntimeData(); _gooseSnapshot = new GooseMonitorSnapshot { StatusText = "Cleared" }; _gooseMessages = Array.Empty(); + _gooseCatalog.Clear(); + _selectedGoosePublisherKey = null; + _gooseInspectHistoricalEvent = false; + UpdateGooseIdFilterOptions(); _gooseHistory.Clear(); _lastGooseState.Clear(); _pendingGooseRows.Clear(); @@ -4307,10 +4366,9 @@ private async Task ClearAsync() _frequencyStable = true; UpdateDebugDisplaySnapshot(); RaiseAll(DateTime.UtcNow, force: true); - OnPropertyChanged(nameof(SelectedGooseMessage)); + OnPropertyChanged(nameof(SelectedGoosePublisherRow)); OnPropertyChanged(nameof(SelectedGooseTrafficRow)); - OnPropertyChanged(nameof(SelectedGooseDatasetValues)); - OnPropertyChanged(nameof(SelectedGooseSemanticText)); + RaiseGooseInspectorProperties(); } private void UpdateRefreshCadence() @@ -4627,7 +4685,8 @@ private void FlushBufferedGooseRows(bool flushAll = false) _gooseHistory.RemoveAt(0); } - _gooseHistoryView.Refresh(); + if (flushed > 0) + _gooseHistoryView.Refresh(); } private void TryFlushBufferedGooseRows() From 508fbdde935728ba0f69e9b0ce95d0dada2e9dd3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:25:13 +0700 Subject: [PATCH 004/102] fix(goose): bind stable publisher rows and inspector to one live or historical snapshot --- src/ProcessBus.App.Wpf/MainWindow.xaml | 46 +++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 98612882..e61c7b1d 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1407,8 +1407,8 @@ FontWeight="SemiBold" Margin="0,0,0,8"/> - @@ -3176,18 +3176,18 @@ Foreground="#F4FAFF" FontSize="16" FontWeight="SemiBold"/> - - + + + @@ -3325,7 +3345,7 @@ @@ -3341,82 +3361,85 @@ TextWrapping="Wrap" Margin="0,0,0,10"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + From d4650c1ca3c2d1e3140d30efe8fa8c91edc4137d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 06:36:10 +0700 Subject: [PATCH 028/102] fix(goose): initial captured values are baseline, not fabricated changes --- .../Analysis/RawProcessBusAnalyzer.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 8c13dd2a..6129f936 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -762,10 +762,9 @@ private static void AnnotateGooseChanges(GooseMessageItem current, GooseMessageI if (previous is null || previous.DataValues.Count == 0) { - foreach (var value in current.DataValues) - value.IsChanged = true; - - current.ChangedSummaryText = SummarizeGooseValues(current.DataValues); + // The first observation has no comparative baseline. Calling every + // value "changed" fabricates a state transition not seen on the wire. + current.ChangedSummaryText = "Initial DataSet baseline"; return; } From 927456d04d8865833eb9aad816f9903b6094d795 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 06:36:13 +0700 Subject: [PATCH 029/102] test(goose): initial arrival must not invent value changes --- tests/ProcessBus.Tests/M4GooseRetentionTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/ProcessBus.Tests/M4GooseRetentionTests.cs b/tests/ProcessBus.Tests/M4GooseRetentionTests.cs index 06eac8e9..5c359387 100644 --- a/tests/ProcessBus.Tests/M4GooseRetentionTests.cs +++ b/tests/ProcessBus.Tests/M4GooseRetentionTests.cs @@ -35,6 +35,17 @@ public void SameGoCbRefOnTwoSourceMacsRemainsTwoIndependentPublishers() Assert.All(publishers, x => Assert.Equal("LABLD0/LLN0$GO$CB001", x.GoCbRef)); } + [Fact] + public void FirstGooseObservationIsAnInitialBaselineNotAChangedSignal() + { + var analyzer = new RawProcessBusAnalyzer(); + analyzer.ObserveFrame(Goose(1), DateTime.UtcNow); + var message = Assert.Single(analyzer.GetGooseSnapshot().Messages); + Assert.NotEmpty(message.DataValues); + Assert.All(message.DataValues, item => Assert.False(item.IsChanged)); + Assert.Equal("Initial DataSet baseline", message.ChangedSummaryText); + } + [Fact] public void ChurningPublisherIdentitiesCannotAllocateUnboundedGooseStates() { From 3aca915563d4d3441581885be65f72a59c3a2e44 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 06:36:16 +0700 Subject: [PATCH 030/102] feat(goose): quality summary always surfaces substituted, test and blocked qualifiers --- src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs b/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs index 706f862e..48185be6 100644 --- a/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs +++ b/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs @@ -62,7 +62,10 @@ public sealed record GooseQualityInfo( bool OperatorBlocked, IReadOnlyList DetailFlags) { - public string Summary => Validity; + public string Summary => Validity + + (Source == "substituted" ? " · substituted" : string.Empty) + + (Test ? " · test" : string.Empty) + + (OperatorBlocked ? " · blocked" : string.Empty); public string DetailText => $"Validity: {Validity} | Source: {Source} | Test: {Test.ToString().ToLowerInvariant()} | " + $"Operator blocked: {OperatorBlocked.ToString().ToLowerInvariant()} | " + From ec5dba44b7181e21eda47dafa5abee6a108abbd7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 06:37:59 +0700 Subject: [PATCH 031/102] fix(goose): reconcile typed DataSet rows without collapsing expanded details during retransmissions --- .../ViewModels/MainWindowViewModel.cs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 5a9cd5b3..5060885e 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -126,6 +126,7 @@ private set ? invalid.Description : "Select a real Npcap capture adapter."; private readonly ObservableCollection _gooseHistory = new(); + private readonly ObservableCollection _gooseInspectorRows = new(); private readonly Dictionary _lastGooseState = new(); private readonly Queue _pendingGooseRows = new(); private readonly Queue _pendingDiagnosticEvents = new(); @@ -176,6 +177,9 @@ public DiagnosticEventItem? SelectedDiagnosticEvent public ObservableCollection GooseHistory => _gooseHistory; public ICollectionView GooseHistoryView => _gooseHistoryView; public ICollectionView GoosePublisherRowsView => _goosePublisherRowsView; + // DataSet row instances survive new st/sq snapshots, preserving expanded + // MMS/quality details while live retransmissions continue. + public ObservableCollection GooseInspectorRows => _gooseInspectorRows; public ObservableCollection DiagnosticTargets => _diagnosticTargets; public TrafficHealthTargetRow? SelectedDiagnosticTarget @@ -931,8 +935,40 @@ public string GooseInspectorModeText } } + private void ReconcileGooseInspectorRows() + { + var latest = BuildGooseDatasetValues(GooseInspectorMessage); + for (var index = 0; index < latest.Count; index++) + { + var item = latest[index]; + if (index < _gooseInspectorRows.Count && _gooseInspectorRows[index].Index == item.Index) + { + _gooseInspectorRows[index].Update(item); + continue; + } + var existing = -1; + for (var other = index + 1; other < _gooseInspectorRows.Count; other++) + if (_gooseInspectorRows[other].Index == item.Index) + { + existing = other; + break; + } + if (existing >= 0) + { + _gooseInspectorRows.Move(existing, index); + _gooseInspectorRows[index].Update(item); + } + else + _gooseInspectorRows.Insert(index, new GooseDatasetDisplayRow(item)); + } + + while (_gooseInspectorRows.Count > latest.Count) + _gooseInspectorRows.RemoveAt(_gooseInspectorRows.Count - 1); + } + private void RaiseGooseInspectorProperties() { + ReconcileGooseInspectorRows(); OnPropertyChanged(nameof(SelectedGooseMessage)); OnPropertyChanged(nameof(GooseInspectorMessage)); OnPropertyChanged(nameof(GooseInspectorDatasetCountText)); @@ -4737,6 +4773,53 @@ private void TryFlushBufferedGooseRows() } } +/// +/// Stable, per-index WPF value row. DataSet snapshots never replace the +/// ItemsControl ItemsSource; only truly changed fields raise UI notifications. +/// +public sealed class GooseDatasetDisplayRow : INotifyPropertyChanged +{ + private GooseDatasetValueDisplayItem _current; + + public GooseDatasetDisplayRow(GooseDatasetValueDisplayItem item) => _current = item; + public int Index => _current.Index; + public string IndexText => _current.IndexText; + public string NameText => _current.NameText; + public string TypeText => _current.TypeText; + public string SemanticText => _current.SemanticText; + public string ValueText => _current.ValueText; + public string DisplayValueText => _current.DisplayValueText; + public string RawHexDisplayText => _current.RawHexDisplayText; + public string ChangeText => _current.ChangeText; + public Visibility ChangeVisibility => _current.ChangeVisibility; + public string QualityDetailsText => _current.QualityDetailsText; + public Visibility QualityDetailsVisibility => _current.QualityDetailsVisibility; + public Brush ValueBrush => _current.ValueBrush; + + public event PropertyChangedEventHandler? PropertyChanged; + + public void Update(GooseDatasetValueDisplayItem next) + { + var previous = _current; + if (previous.Index == next.Index && + previous.NameText == next.NameText && + previous.TypeText == next.TypeText && + previous.SemanticText == next.SemanticText && + previous.ValueText == next.ValueText && + previous.RawHexText == next.RawHexText && + previous.QualitySummaryText == next.QualitySummaryText && + previous.QualityDetailsText == next.QualityDetailsText && + previous.IsChanged == next.IsChanged && + previous.PreviousValueText == next.PreviousValueText) + return; + + _current = next; + // Empty property name means all bound fields of this one row changed. + // WPF preserves the selected/expanded item container object. + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); + } +} + public sealed class GooseDatasetValueDisplayItem { private static readonly Brush NormalValueBrush = FrozenBrush(228, 241, 253); From 7fb156b146f435d02bdc41d1c98fe103d777c11f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 06:38:14 +0700 Subject: [PATCH 032/102] fix(goose): bind stable typed DataSet rows and preserve full signal references in expanded view --- src/ProcessBus.App.Wpf/MainWindow.xaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 98b6347e..05a65f21 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3374,7 +3374,7 @@ Foreground="#7D9DB6" FontWeight="SemiBold" TextAlignment="Right" Margin="0,0,8,0"/> - + + + Date: Wed, 21 Oct 2026 06:40:50 +0700 Subject: [PATCH 033/102] test(ui): update GOOSE inspector width contract while preserving 8-DIP gutter --- scripts/verify-workspace-spacing.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index a5988d1e..d76ddaed 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -56,7 +56,7 @@ Assert-True (Has-Definitions 'Grid.RowDefinitions' 'RowDefinition' 'Height' '0.8 # All five customer-facing workspaces share the same horizontal or vertical rhythm. Assert-True (Has-Definitions 'Grid.RowDefinitions' 'RowDefinition' 'Height' 'Auto,8,Auto,8,Auto,Auto,*') 'Diagnostics primary vertical gutters' -Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '*,8,400') 'GOOSE timeline/inspector gutter' +Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '*,8,460') 'GOOSE timeline/inspector gutter (readable engineering details, 8 DIP separator)' Assert-True (Has-Definitions 'Grid.RowDefinitions' 'RowDefinition' 'Height' 'Auto,8,Auto,8,*') 'Timing/PTP major sections' Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '1.15*,8,1.1*,8,1.2*') 'Timing/PTP header-card gutters' Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '1.1*,8,1.4*') 'Timing/PTP evidence gutter' From bbd123f2bab94a675e3cbde13c1ad91b434a35ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:06:13 +0700 Subject: [PATCH 034/102] feat(goose): coherent live/history highlighting, RX-event counters and stable readable dataset context --- .../ViewModels/MainWindowViewModel.cs | 84 +++++++++++++++++-- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 5060885e..4cd6ab1f 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -47,6 +47,9 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private GoosePublisherKey? _selectedGoosePublisherKey; private GooseTrafficRow? _selectedGooseTrafficRow; private bool _gooseInspectHistoricalEvent; + private GoosePublisherKey? _lastDisplayedGoosePublisherKey; + private GooseTrafficRow? _lastDisplayedGooseHistoryEvent; + private int _gooseVisibleEventCount; private IReadOnlyList _gooseIdFilterOptions = ["All"]; private IReadOnlyList _gooseMessages = Array.Empty(); private GooseMonitorSnapshot _gooseSnapshot = new(); @@ -223,6 +226,7 @@ public bool IncludeGooseRetransmission OnPropertyChanged(nameof(GooseFilterSummaryText)); OnPropertyChanged(nameof(GooseEmptyGuidance)); _gooseHistoryView.Refresh(); + UpdateGooseVisibleEventCount(); } } @@ -239,6 +243,7 @@ public string GoosePublisherFilterText OnPropertyChanged(nameof(GooseFilterSummaryText)); OnPropertyChanged(nameof(GooseEmptyGuidance)); _gooseHistoryView.Refresh(); + UpdateGooseVisibleEventCount(); _goosePublisherRowsView.Refresh(); OnPropertyChanged(nameof(SelectedGoosePublisherRow)); } @@ -280,6 +285,7 @@ public string SelectedGooseIdFilter OnPropertyChanged(nameof(GooseFilterSummaryText)); OnPropertyChanged(nameof(GooseEmptyGuidance)); _gooseHistoryView.Refresh(); + UpdateGooseVisibleEventCount(); _goosePublisherRowsView.Refresh(); OnPropertyChanged(nameof(SelectedGoosePublisherRow)); } @@ -294,6 +300,29 @@ public string SelectedGooseIdFilter : "No matching state changes. Include retransmissions to inspect traffic activity."; public string GooseRetransmissionModeText => IncludeGooseRetransmission ? "Retrans ON" : "Retrans OFF"; public string GoosePublisherCountText => $"{_gooseMessages.Count} publisher{(_gooseMessages.Count == 1 ? string.Empty : "s")}"; + public string GooseTimelineCountersText => + $"{_gooseSnapshot.TotalMessages:N0} RX frames · {_gooseVisibleEventCount:N0} visible event{(_gooseVisibleEventCount == 1 ? "" : "s")}"; + public string GooseWorkspaceCaptureSummaryText + { + get + { + if (!IsRunning) + return "GOOSE capture stopped • Last valid publisher evidence retained"; + var testCount = _gooseMessages.Count(x => x.IsTest); + return $"GOOSE • {_gooseMessages.Count} publisher(s) • {_gooseSnapshot.TotalMessages:N0} RX frames" + + (testCount > 0 ? $" • {testCount} TEST publisher(s)" : string.Empty); + } + } + + private void UpdateGooseVisibleEventCount() + { + // Count the bounded view only on history/filter changes, never per RX frame. + var visible = _gooseHistoryView.Cast().Count(); + if (visible == _gooseVisibleEventCount) + return; + _gooseVisibleEventCount = visible; + OnPropertyChanged(nameof(GooseTimelineCountersText)); + } public string GooseFilterSummaryText { get @@ -858,6 +887,11 @@ public GoosePublisherRow? SelectedGoosePublisherRow _selectedGoosePublisherKey = value.Key; _gooseInspectHistoricalEvent = false; + if (_selectedGooseTrafficRow is not null) + { + _selectedGooseTrafficRow = null; + OnPropertyChanged(nameof(SelectedGooseTrafficRow)); + } BeginWorkspaceTargetTransition(); OnPropertyChanged(); OnPropertyChanged(nameof(IsGooseHistoryPinned)); @@ -906,6 +940,8 @@ private void ReturnGooseToLive() if (!_gooseInspectHistoricalEvent) return; _gooseInspectHistoricalEvent = false; + _selectedGooseTrafficRow = null; + OnPropertyChanged(nameof(SelectedGooseTrafficRow)); OnPropertyChanged(nameof(IsGooseHistoryPinned)); RaiseGooseInspectorProperties(); RaiseAdvancedProperties(); @@ -917,6 +953,23 @@ private void ReturnGooseToLive() public string GooseInspectorDatasetCountText => (GooseInspectorMessage?.DataValues.Count ?? 0).ToString(); + public string GooseInspectorModeBadgeText => IsGooseHistoryPinned + ? "PINNED EVENT" : GooseInspectorMessage is null ? "NO SELECTION" : + IsGooseSelectedPublisherStale ? "STALE" : "LIVE"; + + private bool IsGooseSelectedPublisherStale + { + get + { + var item = SelectedGooseMessage; + if (item is null) + return true; + var ttl = Math.Max(1000.0, item.TimeAllowedToLiveMilliseconds); + return SelectedGoosePublisherRow?.IsStale == true || + DateTime.UtcNow - item.LastSeenUtc > TimeSpan.FromMilliseconds(ttl); + } + } + public string GooseInspectorModeText { get @@ -926,10 +979,7 @@ public string GooseInspectorModeText return "Select a publisher for live values or an event for historical evidence."; if (_gooseInspectHistoricalEvent) return $"Pinned event · {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · historical values"; - var ttl = Math.Max(1000.0, item.TimeAllowedToLiveMilliseconds); - var stale = SelectedGoosePublisherRow?.IsStale == true || - DateTime.UtcNow - item.LastSeenUtc > TimeSpan.FromMilliseconds(ttl); - return stale + return IsGooseSelectedPublisherStale ? $"STALE · last received {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · last valid values retained" : "Live publisher · latest received typed DataSet values"; } @@ -937,6 +987,17 @@ public string GooseInspectorModeText private void ReconcileGooseInspectorRows() { + var liveContext = _gooseInspectHistoricalEvent ? null : _selectedGoosePublisherKey; + var eventContext = _gooseInspectHistoricalEvent ? _selectedGooseTrafficRow : null; + if (_lastDisplayedGoosePublisherKey != liveContext || + !ReferenceEquals(_lastDisplayedGooseHistoryEvent, eventContext)) + { + // Switching publishers or pinning a different event resets expansion + // only for that deliberate context change, not for retransmissions. + _lastDisplayedGoosePublisherKey = liveContext; + _lastDisplayedGooseHistoryEvent = eventContext; + _gooseInspectorRows.Clear(); + } var latest = BuildGooseDatasetValues(GooseInspectorMessage); for (var index = 0; index < latest.Count; index++) { @@ -973,6 +1034,7 @@ private void RaiseGooseInspectorProperties() OnPropertyChanged(nameof(GooseInspectorMessage)); OnPropertyChanged(nameof(GooseInspectorDatasetCountText)); OnPropertyChanged(nameof(GooseInspectorModeText)); + OnPropertyChanged(nameof(GooseInspectorModeBadgeText)); OnPropertyChanged(nameof(SelectedGooseDatasetValues)); OnPropertyChanged(nameof(SelectedGooseSemanticText)); } @@ -2693,6 +2755,8 @@ public string OperatorCaptureSummaryText { get { + if (IsGooseTabActive) + return GooseWorkspaceCaptureSummaryText; if (!IsRunning) return "Capture stopped • Select an Ethernet adapter and press Start"; @@ -2868,6 +2932,7 @@ private void RaiseGooseProperties() OnPropertyChanged(nameof(GooseSnapshot)); OnPropertyChanged(nameof(GooseStatusText)); OnPropertyChanged(nameof(GooseTotalMessagesText)); + OnPropertyChanged(nameof(GooseTimelineCountersText)); OnPropertyChanged(nameof(GooseDetectedCountText)); OnPropertyChanged(nameof(GoosePublisherCountText)); OnPropertyChanged(nameof(GooseFilterSummaryText)); @@ -4399,6 +4464,7 @@ private async Task ClearAsync() OnPropertyChanged(nameof(IsGooseHistoryPinned)); UpdateGooseIdFilterOptions(); _gooseHistory.Clear(); + UpdateGooseVisibleEventCount(); _lastGooseState.Clear(); _pendingGooseRows.Clear(); _pendingDiagnosticEvents.Clear(); @@ -4547,7 +4613,7 @@ private IReadOnlyList BuildGooseDatasetValues(Goos return new GooseDatasetValueDisplayItem { Index = value.Index, - NameText = semantic?.DisplayName ?? (string.IsNullOrWhiteSpace(value.Name) ? $"Entry {value.Index}" : value.Name), + NameText = GooseDatasetEntryPresentation.ResolveName(value.Index, semantic?.DisplayName), TypeText = BuildSemanticGooseTypeText(value, semantic), SemanticText = semantic is null ? "Generic typed decode" : "SCL DataSet entry", ValueText = BuildSemanticGooseValueText(value, semantic), @@ -4748,15 +4814,17 @@ private void FlushBufferedGooseRows(bool flushAll = false) flushed++; } + var removeCount = 0; if (_gooseHistory.Count > GooseHistoryLimit) { - var removeCount = _gooseHistory.Count - GooseHistoryLimit; + removeCount = _gooseHistory.Count - GooseHistoryLimit; for (var i = 0; i < removeCount; i++) _gooseHistory.RemoveAt(0); } - // CollectionView observes ObservableCollection adds/removes and filters - // each new row automatically. Refresh() here resets DataGrid selection. + // The view observes incremental changes. Never Refresh() on each event. + if (flushed > 0 || removeCount > 0) + UpdateGooseVisibleEventCount(); } private void TryFlushBufferedGooseRows() From bfd90eb423671ae485dcf0dfd8e584df96115cad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:06:35 +0700 Subject: [PATCH 035/102] feat(goose): explicitly label unmapped MMS entries without inventing signal names --- .../Presentation/GooseDatasetEntryPresentation.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/ProcessBus.Core/Presentation/GooseDatasetEntryPresentation.cs diff --git a/src/ProcessBus.Core/Presentation/GooseDatasetEntryPresentation.cs b/src/ProcessBus.Core/Presentation/GooseDatasetEntryPresentation.cs new file mode 100644 index 00000000..53c63c9e --- /dev/null +++ b/src/ProcessBus.Core/Presentation/GooseDatasetEntryPresentation.cs @@ -0,0 +1,14 @@ +namespace ProcessBus.Core.Presentation; + +/// +/// Human-readable entry label for unconfigured MMS data. The raw BER decoder +/// knows type and ordinal, not an electrical point name; only a matching SCL +/// DataSet entry can supply a signal reference. +/// +public static class GooseDatasetEntryPresentation +{ + public static string ResolveName(int index, string? matchedSclSignal) + => string.IsNullOrWhiteSpace(matchedSclSignal) + ? $"Entry {index}" + : matchedSclSignal.Trim(); +} From 75eb143ef17256fe40b1b7fa7e6bd39562ca549a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:06:38 +0700 Subject: [PATCH 036/102] test(goose): prove unmapped entries cannot masquerade as SCL signal references --- .../GooseDatasetEntryPresentationTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs diff --git a/tests/ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs b/tests/ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs new file mode 100644 index 00000000..cb700e10 --- /dev/null +++ b/tests/ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs @@ -0,0 +1,16 @@ +using ProcessBus.Core.Presentation; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class GooseDatasetEntryPresentationTests +{ + [Theory] + [InlineData(1, null, "Entry 1")] + [InlineData(2, "", "Entry 2")] + [InlineData(3, " ", "Entry 3")] + [InlineData(4, "IEDLD0/XSWI5.Pos.stVal", "IEDLD0/XSWI5.Pos.stVal")] + [InlineData(5, " IEDLD0/XCBR1.Pos.q ", "IEDLD0/XCBR1.Pos.q")] + public void OnlyAnExplicitSclMappingProvidesASignalName(int index, string? mapped, string expected) + => Assert.Equal(expected, GooseDatasetEntryPresentation.ResolveName(index, mapped)); +} From 0280b06a8a4ebcf76922d2f56b8fa876b1d41367 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:06:59 +0700 Subject: [PATCH 037/102] fix(goose): reject malformed IEC 61850 quality bitstring padding --- src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs b/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs index 48185be6..5e537bf2 100644 --- a/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs +++ b/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs @@ -26,7 +26,9 @@ public static bool TryDecode(string? rawHex, string? mmsType, out GooseQualityIn // IEC 61850 q has exactly 13 bits. The MMS BER bitstring value starts // with the unused-bit count, then the MSB-first quality payload. - if (bytes[0] != 3) + // A 13-bit quality word leaves three unused bits that must be zero. + // Reject malformed BER padding rather than reporting plausible quality. + if (bytes[0] != 3 || (bytes[2] & 0x07) != 0) return false; var qualityBits = (uint)((bytes[1] << 8) | bytes[2]); From 68854913379bb983a63a35990e1b03a79a905b8e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:07:02 +0700 Subject: [PATCH 038/102] test(goose): quality validity variants and malformed padding fail closed --- .../GooseQualityPresentationTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/ProcessBus.Tests/GooseQualityPresentationTests.cs b/tests/ProcessBus.Tests/GooseQualityPresentationTests.cs index a96e1a67..7f5185b5 100644 --- a/tests/ProcessBus.Tests/GooseQualityPresentationTests.cs +++ b/tests/ProcessBus.Tests/GooseQualityPresentationTests.cs @@ -31,10 +31,22 @@ public void ActiveQualityFlagsRemainExplicitAndNotReducedToAColor() Assert.Contains("oldData", quality.DetailFlags); } + [Theory] + [InlineData("034000", "reserved")] + [InlineData("03C000", "questionable")] + [InlineData("038000", "reserved")] + public void QualityValidityVariantsRemainLiteral(string hex, string expected) + { + Assert.True(GooseQualityPresentation.TryDecode(hex, "BitString", out var parsed)); + Assert.Equal(expected, parsed!.Validity); + } + [Theory] [InlineData("", "BitString")] [InlineData("0300", "BitString")] [InlineData("040000", "BitString")] + [InlineData("030001", "BitString")] + [InlineData("030007", "BitString")] [InlineData("03ZZ00", "BitString")] [InlineData("030000", "Boolean")] [InlineData(null, "BitString")] From c66338ea2db701463d4f98ebe129447fc867c32c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:07:16 +0700 Subject: [PATCH 039/102] test(goose): match validity bit-order mapping for invalid quality --- tests/ProcessBus.Tests/GooseQualityPresentationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ProcessBus.Tests/GooseQualityPresentationTests.cs b/tests/ProcessBus.Tests/GooseQualityPresentationTests.cs index 7f5185b5..cc702d4e 100644 --- a/tests/ProcessBus.Tests/GooseQualityPresentationTests.cs +++ b/tests/ProcessBus.Tests/GooseQualityPresentationTests.cs @@ -32,7 +32,7 @@ public void ActiveQualityFlagsRemainExplicitAndNotReducedToAColor() } [Theory] - [InlineData("034000", "reserved")] + [InlineData("034000", "invalid")] [InlineData("03C000", "questionable")] [InlineData("038000", "reserved")] public void QualityValidityVariantsRemainLiteral(string hex, string expected) From 111c61f16b360174409a7b724cac07ee43ddb6ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:08:35 +0700 Subject: [PATCH 040/102] feat(goose-ui): scoped compact chevron disclosure control with keyboard focus and no default white icons --- src/ProcessBus.App.Wpf/Themes/Controls.xaml | 82 +++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/ProcessBus.App.Wpf/Themes/Controls.xaml b/src/ProcessBus.App.Wpf/Themes/Controls.xaml index 638e7615..3c930d1b 100644 --- a/src/ProcessBus.App.Wpf/Themes/Controls.xaml +++ b/src/ProcessBus.App.Wpf/Themes/Controls.xaml @@ -59,6 +59,88 @@ + + + + + + + - @@ -3180,10 +3202,34 @@ - + + + + + + + + + + + + + FontSize="11" Margin="0,5,8,0" TextWrapping="Wrap"/> - + + @@ -3389,18 +3399,21 @@ - - - + + + + + + + - + Foreground="#F0C17F" FontSize="11" + VerticalAlignment="Center"/> + - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - + Foreground="#9ED9E9" FontSize="10.5" + TextWrapping="Wrap"/> + @@ -3491,8 +3520,9 @@ - - + + + From 1a45cf06476ecf905f9c08dcdb9bdf611e20b720 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:45:29 +0700 Subject: [PATCH 048/102] fix(goose-ui): expose compact MMS type/source without recomputing bound row collections --- src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 4cd6ab1f..c6eacfd2 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -4855,6 +4855,7 @@ public sealed class GooseDatasetDisplayRow : INotifyPropertyChanged public string NameText => _current.NameText; public string TypeText => _current.TypeText; public string SemanticText => _current.SemanticText; + public string TypeSourceText => $"{_current.TypeText} · {_current.SemanticText}"; public string ValueText => _current.ValueText; public string DisplayValueText => _current.DisplayValueText; public string RawHexDisplayText => _current.RawHexDisplayText; From 931cc8d07e6c9066a9450ac27095fd56c9a6b087 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:45:50 +0700 Subject: [PATCH 049/102] test(goose-ui): lock sticky header, DataSet count alignment and compact MMS row contract --- scripts/verify-workspace-spacing.ps1 | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index a6459b5c..ba8a2ded 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -101,5 +101,20 @@ foreach ($binding in @( )) { Assert-True ($source.Contains($binding)) "GOOSE inspector contract: $binding" } +# Exact UI defect reported in operator screenshots: Inspector controls must +# remain visible when raw evidence scrolls; count must have its own grid cell. +$inspectorStart = $source.IndexOf('') +$inspectorEnd = $source.IndexOf('', $inspectorStart) +Assert-True ($inspectorStart -ge 0 -and $inspectorEnd -gt $inspectorStart) 'GOOSE inspector markup found' +$inspector = $source.Substring($inspectorStart, $inspectorEnd - $inspectorStart) +$headerOffset = $inspector.IndexOf('Text="GOOSE Inspector"') +$scrollOffset = $inspector.IndexOf('')) 'DataSet heading/count have independent grid columns' +Assert-True ($inspector.Contains('Grid.Column="1" Margin="12,0,0,0"')) 'DataSet entry count has clear separation from heading' +Assert-True ($inspector.Contains('Text="{Binding TypeSourceText}"')) 'Expanded MMS details use concise type/source row' +Assert-True (-not $inspector.Contains('')) 'DataSet title no longer uses ambiguous DockPanel filling' +Write-Host 'GOOSE Inspector screenshot regression gate: PASS (fixed header, count alignment, compact MMS details)' Write-Host 'GOOSE readout contract: PASS (scoped chevron, RX/events, TEST, stable typed rows, live return)' Write-Host 'WPF workspace spacing contract: PASS (8 DIP columns / 8 DIP panel gaps, named insets)' From bb78507a2d2e92cc0a34f7968fdf319d8b27660e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 07:46:51 +0700 Subject: [PATCH 050/102] docs(goose): operator screenshot findings and fixed-header/expanded-evidence acceptance criteria --- .../GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/validation/GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md b/docs/validation/GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md index e7d3781a..a9f4b581 100644 --- a/docs/validation/GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md +++ b/docs/validation/GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md @@ -38,3 +38,11 @@ Windows repository health, workspace layout/source contract, full .NET solution At 1366×768, 1586×893 and 1920×1080 (where available) under 100%, 125%, and 150% display scaling: inspect normal/expanded DataSet rows without clipping, confirm chevron click and keyboard toggle, tab through TEST/LIVE/PINNED/STALE indicators, select multiple GOOSE publishers during continuous retransmissions, pin events, click Return to Live, use filter search/GOOSE ID with no selection loss, and observe updates with real or recorded GOOSE traffic. Import a matching SCL and verify genuine signal/quality mappings, then repeat without SCL to confirm raw-only fallback. **Acceptance boundary:** a successful automated CI build does not prove physical UI readability or IEC 61850 interoperability at a specific field installation. Keep candidate publication and branch promotion separate from this manual sign-off. + +## Follow-up: operator screenshot inspection, expanded raw MMS entries + +Three operator screenshots of the P0–P3 candidate demonstrate selection stability during reception and successful simultaneous expansion of two typed DataSet entries. They also reveal three visual defects: DataSet title/count running together, the Inspector context disappearing when the entire pane scrolls, and unnecessarily tall expanded MMS details. + +The targeted follow-up uses a fixed Inspector header row (including PINNED/TEST and Return to Live), a single scrolling evidence region, an explicitly aligned DataSet title/count grid, and compact key/value MMS details. These corrections do not alter packet reception, GOOSE state matching, generic typed decoding, or the 8-DIP workspace gutter. + +Required physical acceptance: expand two entries, scroll to the bottom and back, confirm the Inspector mode and Return to Live remain visible throughout; confirm the DataSet count does not touch the title at 100%, 125%, 150% DPI; verify selecting a different publisher or returning to Live changes Inspector context correctly without replacing unrelated publisher rows. From 366b341a8e0b289498e11575d152e80c978a96c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:41:28 +0700 Subject: [PATCH 051/102] feat(goose): precise plain-language descriptions without guessing switch state --- .../Presentation/GooseEngineeringLanguage.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/ProcessBus.Core/Presentation/GooseEngineeringLanguage.cs diff --git a/src/ProcessBus.Core/Presentation/GooseEngineeringLanguage.cs b/src/ProcessBus.Core/Presentation/GooseEngineeringLanguage.cs new file mode 100644 index 00000000..fb967e8c --- /dev/null +++ b/src/ProcessBus.Core/Presentation/GooseEngineeringLanguage.cs @@ -0,0 +1,56 @@ +namespace ProcessBus.Core.Presentation; + +/// +/// Operator-friendly wording for GOOSE evidence. Never infer a real-world +/// switch position or quality from an unidentified raw MMS value. +/// Exact protocol names remain available in the expanded evidence view. +/// +public static class GooseEngineeringLanguage +{ + public static string DataItemName(int index, string? sclSignalName) => + string.IsNullOrWhiteSpace(sclSignalName) ? $"Data item {index}" : sclSignalName.Trim(); + + public static string DataType(string? mmsType) => mmsType?.Trim() switch + { + "BitString" => "Binary bit pattern", + "Boolean" => "True / false value", + "Integer" => "Whole number", + "Unsigned" => "Positive whole number", + "Float" => "Decimal number", + "FloatingPoint" => "Decimal number", + "VisibleString" => "Text", + "OctetString" => "Bytes", + "UtcTime" => "Date and time", + "BinaryTime" => "Time value", + "Structure" => "Group of values", + "Array" => "List of values", + "Enum" => "Enumerated value", + "Enumerated" => "Enumerated value", + "Unknown" or null or "" => "Unknown data type", + var other => $"Recorded type: {other}" + }; + + public static string SignalSource(bool matchedScl) => matchedScl + ? "Signal name identified from the imported configuration" + : "Signal not identified — configuration file not matched"; + + public static string Event(string? eventType) => eventType switch + { + "New" => "First seen", + "State Change" => "State changed", + "Retrans" => "Repeated", + _ => "Message" + }; + + public static string QualitySummary(string? rawQualitySummary) + { + if (string.IsNullOrWhiteSpace(rawQualitySummary)) + return ""; + return rawQualitySummary.Replace("questionable", "Questionable quality", StringComparison.Ordinal) + .Replace("invalid", "Invalid quality", StringComparison.Ordinal) + .Replace("reserved", "Reserved quality code", StringComparison.Ordinal) + .Replace("good", "Good quality", StringComparison.Ordinal) + .Replace("substituted", "Substituted", StringComparison.Ordinal) + .Replace("blocked", "Operator blocked", StringComparison.Ordinal); + } +} From 6cc60c90b3ab99b7b8c4934ab1f411d578d4eaf0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:41:31 +0700 Subject: [PATCH 052/102] test(goose): positive/negative readable-type and quality descriptions --- .../GooseEngineeringLanguageTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs diff --git a/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs b/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs new file mode 100644 index 00000000..074a7b37 --- /dev/null +++ b/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs @@ -0,0 +1,44 @@ +using ProcessBus.Core.Presentation; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class GooseEngineeringLanguageTests +{ + [Theory] + [InlineData("BitString", "Binary bit pattern")] + [InlineData("Boolean", "True / false value")] + [InlineData("Integer", "Whole number")] + [InlineData("OctetString", "Bytes")] + [InlineData("UtcTime", "Date and time")] + [InlineData("NONSPECIFIC_VENDOR_CODE", "Recorded type: NONSPECIFIC_VENDOR_CODE")] + [InlineData(null, "Unknown data type")] + public void KnownAndUnknownWireTypesHaveHonestPlainDescriptions(string? wire, string expected) => + Assert.Equal(expected, GooseEngineeringLanguage.DataType(wire)); + + [Fact] + public void UnmappedBitStringMustNotBecomeBreakerOpenOrClose() + { + var display = GooseEngineeringLanguage.DataType("BitString"); + Assert.Equal("Binary bit pattern", display); + Assert.DoesNotContain("OPEN", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("CLOSE", display, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Data item 1", GooseEngineeringLanguage.DataItemName(1, null)); + } + + [Theory] + [InlineData("New", "First seen")] + [InlineData("State Change", "State changed")] + [InlineData("Retrans", "Repeated")] + public void EventLabelsExplainReceiveHistoryWithoutChangingEngineEvents(string wire, string expected) => + Assert.Equal(expected, GooseEngineeringLanguage.Event(wire)); + + [Fact] + public void QualityInterpretationMustBeExplicitlyIdentified() + { + Assert.Equal("", GooseEngineeringLanguage.QualitySummary("")); + Assert.Equal("Good quality", GooseEngineeringLanguage.QualitySummary("good")); + Assert.Equal("Invalid quality · Substituted · Operator blocked", + GooseEngineeringLanguage.QualitySummary("invalid · substituted · blocked")); + } +} From 2bdad5f403d5a1820e19454f17d070111fe3beb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:42:14 +0700 Subject: [PATCH 053/102] feat(goose): convert operator readouts into descriptive engineering language while preserving raw protocol evidence --- .../ViewModels/MainWindowViewModel.cs | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index c6eacfd2..021db38a 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -292,25 +292,26 @@ public string SelectedGooseIdFilter } public string GooseEmptyTitle => _gooseMessages.Count == 0 - ? "No GOOSE publisher observed" : "No matching GOOSE events"; + ? "No sending device found" : "No messages match your filters"; public string GooseEmptyGuidance => _gooseMessages.Count == 0 - ? "Check physical adapter, VLAN visibility and publisher traffic." + ? "Check the selected network adapter, incoming traffic, and network group settings." : IncludeGooseRetransmission - ? "Review the selected GOOSE ID filter and capture path." - : "No matching state changes. Include retransmissions to inspect traffic activity."; - public string GooseRetransmissionModeText => IncludeGooseRetransmission ? "Retrans ON" : "Retrans OFF"; + ? "Check the message-name filter and search text." + : "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} publisher{(_gooseMessages.Count == 1 ? string.Empty : "s")}"; public string GooseTimelineCountersText => - $"{_gooseSnapshot.TotalMessages:N0} RX frames · {_gooseVisibleEventCount:N0} visible event{(_gooseVisibleEventCount == 1 ? "" : "s")}"; + $"{_gooseSnapshot.TotalMessages:N0} messages received · {_gooseVisibleEventCount:N0} shown"; public string GooseWorkspaceCaptureSummaryText { get { if (!IsRunning) - return "GOOSE capture stopped • Last valid publisher evidence retained"; + return "GOOSE capture stopped • Last received values kept for inspection"; var testCount = _gooseMessages.Count(x => x.IsTest); - return $"GOOSE • {_gooseMessages.Count} publisher(s) • {_gooseSnapshot.TotalMessages:N0} RX frames" + - (testCount > 0 ? $" • {testCount} TEST publisher(s)" : string.Empty); + return $"GOOSE • {_gooseMessages.Count} sending device(s) • {_gooseSnapshot.TotalMessages:N0} messages received" + + (testCount > 0 ? $" • {testCount} device(s) sending test messages" : string.Empty); } } @@ -327,9 +328,9 @@ public string GooseFilterSummaryText { get { - var filter = string.Equals(SelectedGooseIdFilter, "All", StringComparison.OrdinalIgnoreCase) ? "All GOOSE IDs" : SelectedGooseIdFilter; - var retrans = IncludeGooseRetransmission ? "with retransmission" : "state changes only"; - return $"{filter} - {retrans}"; + var filter = string.Equals(SelectedGooseIdFilter, "All", StringComparison.OrdinalIgnoreCase) ? "All message names" : SelectedGooseIdFilter; + var retrans = IncludeGooseRetransmission ? "including repeated messages" : "first messages and state changes"; + return $"{filter} · {retrans}"; } } @@ -342,15 +343,15 @@ public string SelectedGooseSemanticText { var inspected = GooseInspectorMessage; if (inspected is null) - return "Select a GOOSE publisher or timeline event to inspect typed DataSet values."; + return "Select a sending device or a recorded message to see its values."; var match = ResolveSclGooseStream(inspected); if (match is null) return HasSclProject - ? "Semantic source: generic typed decode; no matching SCL GOOSE DataSet found." - : "Semantic source: generic typed decode; load SCL to resolve signal names."; + ? "Signal names unavailable: the imported configuration does not match this message." + : "Signal names unavailable. Open the Configuration tab and import the device configuration (SCL) to identify these values."; - return $"Semantic source: SCL DataSet order - {match.ControlBlockReference} - {match.DataSetReference}"; + return "Signal names taken from the matching device configuration (SCL)."; } } @@ -954,8 +955,8 @@ private void ReturnGooseToLive() (GooseInspectorMessage?.DataValues.Count ?? 0).ToString(); public string GooseInspectorModeBadgeText => IsGooseHistoryPinned - ? "PINNED EVENT" : GooseInspectorMessage is null ? "NO SELECTION" : - IsGooseSelectedPublisherStale ? "STALE" : "LIVE"; + ? "SAVED MESSAGE" : GooseInspectorMessage is null ? "NOT SELECTED" : + IsGooseSelectedPublisherStale ? "NOT RECEIVING" : "LIVE"; private bool IsGooseSelectedPublisherStale { @@ -976,12 +977,12 @@ public string GooseInspectorModeText { var item = GooseInspectorMessage; if (item is null) - return "Select a publisher for live values or an event for historical evidence."; + return "Choose a sending device for current values or a message for recorded values."; if (_gooseInspectHistoricalEvent) - return $"Pinned event · {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · historical values"; + return $"Recorded message at {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · values captured at that time"; return IsGooseSelectedPublisherStale - ? $"STALE · last received {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · last valid values retained" - : "Live publisher · latest received typed DataSet values"; + ? $"No recent messages · last received {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff}" + : "Receiving messages · showing the latest recorded values"; } } @@ -4613,14 +4614,16 @@ private IReadOnlyList BuildGooseDatasetValues(Goos return new GooseDatasetValueDisplayItem { Index = value.Index, - NameText = GooseDatasetEntryPresentation.ResolveName(value.Index, semantic?.DisplayName), - TypeText = BuildSemanticGooseTypeText(value, semantic), - SemanticText = semantic is null ? "Generic typed decode" : "SCL DataSet entry", + NameText = GooseDatasetEntryPresentation.ResolveName(value.Index, semantic?.SignalReference), + TypeText = GooseEngineeringLanguage.DataType(value.Type), + ProtocolTypeText = value.Type, + ConfigurationTypeText = semantic is null ? "" : BuildSemanticGooseTypeText(value, semantic), + SemanticText = GooseEngineeringLanguage.SignalSource(semantic is not null && !string.IsNullOrWhiteSpace(semantic.SignalReference)), ValueText = BuildSemanticGooseValueText(value, semantic), RawHexText = value.RawHex, - QualitySummaryText = decodedQuality?.Summary ?? string.Empty, + QualitySummaryText = GooseEngineeringLanguage.QualitySummary(decodedQuality?.Summary), QualityDetailsText = decodedQuality?.DetailText ?? - (isMappedQuality ? "Quality q not decoded; inspect the original MMS bits." : string.Empty), + (isMappedQuality ? "Signal quality could not be interpreted; check its original bit pattern." : string.Empty), IsChanged = value.IsChanged, PreviousValueText = string.IsNullOrWhiteSpace(value.PreviousValue) ? value.PreviousValue @@ -4854,6 +4857,8 @@ public sealed class GooseDatasetDisplayRow : INotifyPropertyChanged public string IndexText => _current.IndexText; public string NameText => _current.NameText; public string TypeText => _current.TypeText; + public string ProtocolTypeText => _current.ProtocolTypeText; + public string ConfigurationTypeText => _current.ConfigurationTypeText; public string SemanticText => _current.SemanticText; public string TypeSourceText => $"{_current.TypeText} · {_current.SemanticText}"; public string ValueText => _current.ValueText; @@ -4873,6 +4878,8 @@ public void Update(GooseDatasetValueDisplayItem next) if (previous.Index == next.Index && previous.NameText == next.NameText && previous.TypeText == next.TypeText && + previous.ProtocolTypeText == next.ProtocolTypeText && + previous.ConfigurationTypeText == next.ConfigurationTypeText && previous.SemanticText == next.SemanticText && previous.ValueText == next.ValueText && previous.RawHexText == next.RawHexText && @@ -4896,8 +4903,10 @@ public sealed class GooseDatasetValueDisplayItem public int Index { get; init; } public string NameText { get; init; } = string.Empty; - public string TypeText { get; init; } = "Unknown"; - public string SemanticText { get; init; } = "Generic typed decode"; + public string TypeText { get; init; } = "Unknown data type"; + public string ProtocolTypeText { get; init; } = "Unknown"; + public string ConfigurationTypeText { get; init; } = string.Empty; + public string SemanticText { get; init; } = "Signal not identified"; public string ValueText { get; init; } = "-"; public string RawHexText { get; init; } = string.Empty; public string QualitySummaryText { get; init; } = string.Empty; @@ -4912,7 +4921,7 @@ public sealed class GooseDatasetValueDisplayItem public string IndexText => Index.ToString(System.Globalization.CultureInfo.InvariantCulture); public string RawHexDisplayText => string.IsNullOrWhiteSpace(RawHexText) ? "Not available" : RawHexText; public string ChangeText => IsChanged && !string.IsNullOrWhiteSpace(PreviousValueText) - ? $"Changed from {PreviousValueText}" : IsChanged ? "Changed (previous value unavailable)" : string.Empty; + ? $"Previously: {PreviousValueText}" : IsChanged ? "Value changed (earlier value unavailable)" : string.Empty; public Visibility ChangeVisibility => IsChanged ? Visibility.Visible : Visibility.Collapsed; public Visibility QualityDetailsVisibility => string.IsNullOrWhiteSpace(QualityDetailsText) ? Visibility.Collapsed : Visibility.Visible; @@ -4944,10 +4953,8 @@ public sealed class GooseTrafficRow public uint SqNum => Source.SqNum; public string StatusText => Source.StatusText; public string ValuesText => Source.ValuesText; - public string DisplayStatusText => - string.IsNullOrWhiteSpace(Source.StatusText) || string.Equals(Source.StatusText, "N/A", StringComparison.OrdinalIgnoreCase) - ? EventType - : Source.StatusText; + public string DisplayStatusText => GooseEngineeringLanguage.Event(EventType); + public string EventDescriptionText => GooseEngineeringLanguage.Event(EventType); public string DisplayValuesText { @@ -4968,7 +4975,7 @@ public string DisplayValuesText } if (Source.DataValues.Count == 0) - return "View details"; + return "Open message to see values"; var preview = Source.DataValues .Take(2) From 5014de3b9225f9d5f3c897eeb23014de7266c03b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:46:02 +0700 Subject: [PATCH 054/102] feat(goose-ui): use friendly sending-device and message filtering terms --- src/ProcessBus.App.Wpf/MainWindow.xaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 21584076..deebcb1a 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1307,7 +1307,7 @@ - + ToolTip="Search by device, message name, network address, application code or published data"/> @@ -1343,7 +1343,7 @@ - @@ -1389,7 +1389,7 @@ - - Date: Wed, 21 Oct 2026 08:47:26 +0700 Subject: [PATCH 055/102] feat(goose-ui): readable message, network and data-value labels with full original evidence retained --- src/ProcessBus.App.Wpf/MainWindow.xaml | 92 ++++++++++++++------------ 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index deebcb1a..8528cf5b 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3023,7 +3023,7 @@ - - + - - + - + - + @@ -3365,16 +3367,16 @@ - + - + - + - + - + @@ -3406,11 +3408,11 @@ - @@ -3427,9 +3429,9 @@ - - @@ -3486,28 +3488,34 @@ + - - - - - - + + Date: Wed, 21 Oct 2026 08:48:22 +0700 Subject: [PATCH 056/102] fix(goose): label unnamed data as understandable numbered items --- .../Presentation/GooseDatasetEntryPresentation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProcessBus.Core/Presentation/GooseDatasetEntryPresentation.cs b/src/ProcessBus.Core/Presentation/GooseDatasetEntryPresentation.cs index 53c63c9e..cddcf02f 100644 --- a/src/ProcessBus.Core/Presentation/GooseDatasetEntryPresentation.cs +++ b/src/ProcessBus.Core/Presentation/GooseDatasetEntryPresentation.cs @@ -9,6 +9,6 @@ public static class GooseDatasetEntryPresentation { public static string ResolveName(int index, string? matchedSclSignal) => string.IsNullOrWhiteSpace(matchedSclSignal) - ? $"Entry {index}" + ? $"Data item {index}" : matchedSclSignal.Trim(); } From 66fd46a99c068cb22bd90b55625c3a354c9cd310 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:48:26 +0700 Subject: [PATCH 057/102] test(goose): verify simple data-item names without a matching configuration --- .../ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs b/tests/ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs index cb700e10..7b6d2bf2 100644 --- a/tests/ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs +++ b/tests/ProcessBus.Tests/GooseDatasetEntryPresentationTests.cs @@ -6,9 +6,9 @@ namespace ProcessBus.Tests; public sealed class GooseDatasetEntryPresentationTests { [Theory] - [InlineData(1, null, "Entry 1")] - [InlineData(2, "", "Entry 2")] - [InlineData(3, " ", "Entry 3")] + [InlineData(1, null, "Data item 1")] + [InlineData(2, "", "Data item 2")] + [InlineData(3, " ", "Data item 3")] [InlineData(4, "IEDLD0/XSWI5.Pos.stVal", "IEDLD0/XSWI5.Pos.stVal")] [InlineData(5, " IEDLD0/XCBR1.Pos.q ", "IEDLD0/XCBR1.Pos.q")] public void OnlyAnExplicitSclMappingProvidesASignalName(int index, string? mapped, string expected) From 4ffe8db2b0ca7ec450ffccc46dbc9f6833544046 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:49:33 +0700 Subject: [PATCH 058/102] feat(goose): friendly event summaries, descriptive counts and signal identification guidance --- .../ViewModels/MainWindowViewModel.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 021db38a..14a556a0 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -300,7 +300,7 @@ public string SelectedGooseIdFilter : "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} publisher{(_gooseMessages.Count == 1 ? string.Empty : "s")}"; + public string GoosePublisherCountText => $"{_gooseMessages.Count} device{(_gooseMessages.Count == 1 ? "" : "s")}"; public string GooseTimelineCountersText => $"{_gooseSnapshot.TotalMessages:N0} messages received · {_gooseVisibleEventCount:N0} shown"; public string GooseWorkspaceCaptureSummaryText @@ -806,6 +806,7 @@ public MainWindowViewModel() }); GooseInteractionCommand = new RelayCommand(() => DeferGooseUiFlush()); GooseReturnToLiveCommand = new RelayCommand(ReturnGooseToLive); + GooseOpenConfigurationCommand = new RelayCommand(() => CurrentWorkspaceTabIndex = 5); _timer = new DispatcherTimer { @@ -954,6 +955,15 @@ private void ReturnGooseToLive() public string GooseInspectorDatasetCountText => (GooseInspectorMessage?.DataValues.Count ?? 0).ToString(); + public bool GooseSignalNamesUnavailable + { + get + { + var item = GooseInspectorMessage; + return item is not null && ResolveSclGooseStream(item) is null; + } + } + public string GooseInspectorModeBadgeText => IsGooseHistoryPinned ? "SAVED MESSAGE" : GooseInspectorMessage is null ? "NOT SELECTED" : IsGooseSelectedPublisherStale ? "NOT RECEIVING" : "LIVE"; @@ -1038,6 +1048,7 @@ private void RaiseGooseInspectorProperties() OnPropertyChanged(nameof(GooseInspectorModeBadgeText)); OnPropertyChanged(nameof(SelectedGooseDatasetValues)); OnPropertyChanged(nameof(SelectedGooseSemanticText)); + OnPropertyChanged(nameof(GooseSignalNamesUnavailable)); } public GooseMonitorSnapshot GooseSnapshot => _gooseSnapshot; @@ -1194,6 +1205,7 @@ private void ApplySelectedStreamScale(string? streamId) public ICommand SwitchToRawCommand { get; } public ICommand GooseInteractionCommand { get; } public ICommand GooseReturnToLiveCommand { get; } + public ICommand GooseOpenConfigurationCommand { get; } public ICommand CopyEvidenceCommand { get; } public ICommand LoadSclCommand { get; } public ICommand InspectPcapCommand { get; } @@ -2002,7 +2014,7 @@ private string OperatorSvGuidanceText ? "SV • Select a stream from the explorer" : $"SV • {SelectedStream.SvId} • {SelectedStream.AppId} • {SelectedStream.DisplayStatusText}", 1 => $"Diagnostics - {HealthText} - {NetworkHealthText}", - 2 => $"GOOSE - {_gooseMessages.Count} publisher(s) - {_gooseHistory.Count} event(s)", + 2 => $"GOOSE · {_gooseMessages.Count} sending device(s) · {_gooseHistory.Count} recorded change(s)", 3 => $"Timing/PTP - {PtpStatusCompactText} - {PtpDomainText}", 4 => $"Validation - {ValidationOverallStatusText} - expected-vs-observed evidence", 5 => $"SCL - {_sclDocuments.Count} document(s) - {_sclIedCards.Count} IED(s)", @@ -4230,6 +4242,7 @@ private void RaiseSclProperties() OnPropertyChanged(nameof(SclWorkspaceStatusText)); OnPropertyChanged(nameof(SelectedGooseDatasetValues)); OnPropertyChanged(nameof(SelectedGooseSemanticText)); + OnPropertyChanged(nameof(GooseSignalNamesUnavailable)); RaiseSclSelectionProperties(); OnPropertyChanged(nameof(WorkspaceFooterLeftText)); OnPropertyChanged(nameof(WorkspaceFooterRightText)); @@ -4659,8 +4672,11 @@ private string BuildSemanticGooseChangedSummary(GooseMessageItem goose) return string.Join(", ", changed) + suffix; } - return string.IsNullOrWhiteSpace(goose.ChangedSummaryText) || string.Equals(goose.ChangedSummaryText, "N/A", StringComparison.OrdinalIgnoreCase) - ? "No dataset value change" + if (string.Equals(goose.ChangedSummaryText, "Initial DataSet baseline", StringComparison.Ordinal)) + return "First message received — initial values"; + return string.IsNullOrWhiteSpace(goose.ChangedSummaryText) || + string.Equals(goose.ChangedSummaryText, "N/A", StringComparison.OrdinalIgnoreCase) + ? "No value changes observed" : goose.ChangedSummaryText; } From 5b8ab30b4d4c3a7157aad799eb27bfc98c197763 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:50:00 +0700 Subject: [PATCH 059/102] feat(goose-ui): inline signal-configuration guidance and accessible plain-language source cards --- src/ProcessBus.App.Wpf/MainWindow.xaml | 34 +++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 8528cf5b..97bc5f80 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1307,7 +1307,7 @@ - - - + @@ -3417,10 +3417,32 @@ VerticalAlignment="Center"/> + Margin="0,0,0,5"/> + From be5b202c363a3cbc7c3a7c74722d9cb96541c2d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:50:46 +0700 Subject: [PATCH 060/102] fix(goose): align event timestamps to local time and gate breaker-state meaning on verified DPC.stVal --- .../ViewModels/MainWindowViewModel.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 14a556a0..c84bb4c0 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -4686,7 +4686,13 @@ private static string BuildSemanticGooseValueText(GooseDatasetValueItem value, S if (semantic is null) return text; - if (string.Equals(semantic.Cdc, "DPC", StringComparison.OrdinalIgnoreCase)) + // Only a DataSet attribute explicitly resolved as DPC.stVal may be + // interpreted as a two-bit breaker/switch position. Quality/time/raw + // binary fields must not be mislabeled OPEN or CLOSE. + if (!semantic.IsQuality && !semantic.IsTimestamp && + string.Equals(semantic.Cdc, "DPC", StringComparison.OrdinalIgnoreCase) && + string.Equals(semantic.DaName, "stVal", StringComparison.OrdinalIgnoreCase) && + string.Equals(value.Type, "BitString", StringComparison.OrdinalIgnoreCase)) { return text switch { @@ -4959,7 +4965,8 @@ public sealed class GooseTrafficRow public double? DeltaMs { get; init; } public string SemanticChangedSummaryText { get; init; } = string.Empty; - public string TimeText => Source.LastSeenUtc.ToString("HH:mm:ss.fff"); + // Timeline and Inspector must use the same operator-local clock. + public string TimeText => Source.LastSeenUtc.ToLocalTime().ToString("HH:mm:ss.fff"); public string DeltaText => DeltaMs.HasValue ? $"{DeltaMs.Value:0.0}" : "-"; public string GoId => Source.GoId; From 8d9286962ff226a5ad5536c158777fa6de5590f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:51:36 +0700 Subject: [PATCH 061/102] fix(goose): interpret double-point state only from verified configuration attributes --- .../Presentation/GooseEngineeringLanguage.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/ProcessBus.Core/Presentation/GooseEngineeringLanguage.cs b/src/ProcessBus.Core/Presentation/GooseEngineeringLanguage.cs index fb967e8c..d1228b72 100644 --- a/src/ProcessBus.Core/Presentation/GooseEngineeringLanguage.cs +++ b/src/ProcessBus.Core/Presentation/GooseEngineeringLanguage.cs @@ -1,3 +1,5 @@ +using ProcessBus.Core.Models.Scl; + namespace ProcessBus.Core.Presentation; /// @@ -42,6 +44,35 @@ public static string SignalSource(bool matchedScl) => matchedScl _ => "Message" }; + /// + /// Decode DPC.stVal only when the loaded configuration identifies the exact + /// signal. Do not turn unidentified bit patterns into physical switch states. + /// + public static bool TryDescribeDoublePoint( + SclDataSetEntryModel? signal, string? wireType, string? twoBits, + out string description) + { + description = string.Empty; + if (signal is null || signal.IsQuality || signal.IsTimestamp || + !string.Equals(signal.Cdc, "DPC", StringComparison.OrdinalIgnoreCase) || + !string.Equals(signal.DaName, "stVal", StringComparison.OrdinalIgnoreCase) || + !string.Equals(wireType, "BitString", StringComparison.OrdinalIgnoreCase)) + return false; + + var switchPosition = string.Equals(signal.DoName, "Pos", StringComparison.OrdinalIgnoreCase) && + (string.Equals(signal.LnClass, "XCBR", StringComparison.OrdinalIgnoreCase) || + string.Equals(signal.LnClass, "XSWI", StringComparison.OrdinalIgnoreCase)); + description = twoBits switch + { + "00" => "Between positions (00)", + "01" => switchPosition ? "Open (01)" : "Off (01)", + "10" => switchPosition ? "Closed (10)" : "On (10)", + "11" => "Invalid position (11)", + _ => string.Empty + }; + return description.Length != 0; + } + public static string QualitySummary(string? rawQualitySummary) { if (string.IsNullOrWhiteSpace(rawQualitySummary)) From 987a98636312bc73138794ebed13d12fe4582e40 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:51:56 +0700 Subject: [PATCH 062/102] fix(goose): use audited pure state formatter for SCL-matched circuit breaker signals --- .../ViewModels/MainWindowViewModel.cs | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index c84bb4c0..da4a78a8 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -4686,23 +4686,8 @@ private static string BuildSemanticGooseValueText(GooseDatasetValueItem value, S if (semantic is null) return text; - // Only a DataSet attribute explicitly resolved as DPC.stVal may be - // interpreted as a two-bit breaker/switch position. Quality/time/raw - // binary fields must not be mislabeled OPEN or CLOSE. - if (!semantic.IsQuality && !semantic.IsTimestamp && - string.Equals(semantic.Cdc, "DPC", StringComparison.OrdinalIgnoreCase) && - string.Equals(semantic.DaName, "stVal", StringComparison.OrdinalIgnoreCase) && - string.Equals(value.Type, "BitString", StringComparison.OrdinalIgnoreCase)) - { - return text switch - { - "10" => "CLOSE [10]", - "01" => "OPEN [01]", - "00" => "INTERMEDIATE [00]", - "11" => "BAD_STATE [11]", - _ => text - }; - } + if (GooseEngineeringLanguage.TryDescribeDoublePoint(semantic, value.Type, text, out var state)) + return state; if (string.Equals(semantic.Cdc, "SPS", StringComparison.OrdinalIgnoreCase) && bool.TryParse(text, out var singlePoint)) From 27bb65c0f65142d46a383580b7c6c1a91d18a5cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:52:25 +0700 Subject: [PATCH 063/102] test(goose): positive and negative circuit breaker state interpretation contracts --- .../GooseEngineeringLanguageTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs b/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs index 074a7b37..61e6f097 100644 --- a/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs +++ b/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs @@ -1,4 +1,5 @@ using ProcessBus.Core.Presentation; +using ProcessBus.Core.Models.Scl; using Xunit; namespace ProcessBus.Tests; @@ -33,6 +34,34 @@ public void UnmappedBitStringMustNotBecomeBreakerOpenOrClose() public void EventLabelsExplainReceiveHistoryWithoutChangingEngineEvents(string wire, string expected) => Assert.Equal(expected, GooseEngineeringLanguage.Event(wire)); + [Fact] + public void CircuitBreakerPositionNeedsVerifiedSignalMetadata() + { + var position = new SclDataSetEntryModel + { + Cdc = "DPC", LnClass = "XCBR", DoName = "Pos", DaName = "stVal" + }; + Assert.True(GooseEngineeringLanguage.TryDescribeDoublePoint(position, "BitString", "10", out var closed)); + Assert.Equal("Closed (10)", closed); + Assert.True(GooseEngineeringLanguage.TryDescribeDoublePoint(position, "BitString", "01", out var open)); + Assert.Equal("Open (01)", open); + Assert.True(GooseEngineeringLanguage.TryDescribeDoublePoint(position, "BitString", "00", out var moving)); + Assert.Equal("Between positions (00)", moving); + } + + [Fact] + public void QualityAndUnmappedValuesCanNeverInventBreakerPositions() + { + var raw = new SclDataSetEntryModel { Cdc = "DPC", LnClass = "XCBR", DoName = "Pos", DaName = "stVal", IsQuality = true }; + Assert.False(GooseEngineeringLanguage.TryDescribeDoublePoint(null, "BitString", "10", out _)); + Assert.False(GooseEngineeringLanguage.TryDescribeDoublePoint(raw, "BitString", "10", out _)); + var wrongAttribute = new SclDataSetEntryModel { Cdc = "DPC", LnClass = "XCBR", DoName = "Pos", DaName = "q" }; + Assert.False(GooseEngineeringLanguage.TryDescribeDoublePoint(wrongAttribute, "BitString", "10", out _)); + var position = new SclDataSetEntryModel { Cdc = "DPC", LnClass = "XCBR", DoName = "Pos", DaName = "stVal" }; + Assert.False(GooseEngineeringLanguage.TryDescribeDoublePoint(position, "Boolean", "10", out _)); + Assert.False(GooseEngineeringLanguage.TryDescribeDoublePoint(position, "BitString", "unknown", out _)); + } + [Fact] public void QualityInterpretationMustBeExplicitlyIdentified() { From 23aab018b4c5c4e9881a67ef67fc4a8ad7f8e6d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:52:48 +0700 Subject: [PATCH 064/102] feat(goose): show decoded quality details in plain engineering language --- .../Presentation/GooseQualityPresentation.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs b/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs index 5e537bf2..a652f02b 100644 --- a/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs +++ b/src/ProcessBus.Core/Presentation/GooseQualityPresentation.cs @@ -69,7 +69,9 @@ public sealed record GooseQualityInfo( (Test ? " · test" : string.Empty) + (OperatorBlocked ? " · blocked" : string.Empty); public string DetailText => - $"Validity: {Validity} | Source: {Source} | Test: {Test.ToString().ToLowerInvariant()} | " + - $"Operator blocked: {OperatorBlocked.ToString().ToLowerInvariant()} | " + - $"Detail quality flags: {(DetailFlags.Count == 0 ? "none asserted" : string.Join(", ", DetailFlags))}"; + $"Signal quality: {GooseEngineeringLanguage.QualitySummary(Validity)} · " + + $"Value source: {(Source == "substituted" ? "Substituted value" : "Process value")} · " + + $"Test data: {(Test ? "Yes" : "No")} · " + + $"Operator blocked: {(OperatorBlocked ? "Yes" : "No")} · " + + $"Additional quality flags: {(DetailFlags.Count == 0 ? "None" : string.Join(", ", DetailFlags))}"; } From 5471fd148c84ece1439ade45d086b96920ec04e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:53:40 +0700 Subject: [PATCH 065/102] feat(goose): show original matched configuration types only in advanced entry details --- src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index da4a78a8..2dea3540 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -4866,6 +4866,8 @@ public sealed class GooseDatasetDisplayRow : INotifyPropertyChanged public string TypeText => _current.TypeText; public string ProtocolTypeText => _current.ProtocolTypeText; public string ConfigurationTypeText => _current.ConfigurationTypeText; + public Visibility ConfigurationTypeVisibility => string.IsNullOrWhiteSpace(_current.ConfigurationTypeText) + ? Visibility.Collapsed : Visibility.Visible; public string SemanticText => _current.SemanticText; public string TypeSourceText => $"{_current.TypeText} · {_current.SemanticText}"; public string ValueText => _current.ValueText; From 92a12d438e4e93255b4f8a30e9f21876800a08d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:54:01 +0700 Subject: [PATCH 066/102] feat(goose-ui): reveal verified technical configuration fields only when matching SCL exists --- src/ProcessBus.App.Wpf/MainWindow.xaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 97bc5f80..7e58c5c2 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3511,6 +3511,7 @@ + @@ -3538,6 +3539,12 @@ + Date: Wed, 21 Oct 2026 08:54:37 +0700 Subject: [PATCH 067/102] test(goose-ui): enforce readable operator labels and preserved original protocol evidence --- scripts/verify-workspace-spacing.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index ba8a2ded..858e892c 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -113,7 +113,18 @@ Assert-True ($headerOffset -ge 0 -and $scrollOffset -gt $headerOffset) 'Inspecto Assert-True ($inspector.Contains('Grid.Row="0" Margin="0,0,0,12"')) 'Inspector header stays in fixed grid row' Assert-True ($inspector.Contains('')) 'DataSet heading/count have independent grid columns' Assert-True ($inspector.Contains('Grid.Column="1" Margin="12,0,0,0"')) 'DataSet entry count has clear separation from heading' -Assert-True ($inspector.Contains('Text="{Binding TypeSourceText}"')) 'Expanded MMS details use concise type/source row' +Assert-True ($inspector.Contains('Text="{Binding TypeText}"')) 'Expanded signal kind uses friendly type description' +Assert-True ($inspector.Contains('Text="{Binding ProtocolTypeText}"')) 'Exact original wire type stays available in expanded evidence' +Assert-True ($inspector.Contains('Text="{Binding ConfigurationTypeText}"')) 'Matched configuration type remains available as technical evidence' +Assert-True ($inspector.Contains('Text="State number"')) 'State number is named rather than abbreviated' +Assert-True ($inspector.Contains('Text="Sequence number"')) 'Sequence number is named rather than abbreviated' +Assert-True ($inspector.Contains('Text="Message name"')) 'Message name explains the GOOSE identifier' +Assert-True ($inspector.Contains('Text="Allowed wait"')) 'Time allowed to live uses understandable wording' +Assert-True ($source.Contains('Text="GOOSE Message History"')) 'History describes what operators are reading' +Assert-True ($source.Contains('Text="{Binding EventDescriptionText}"')) 'Recorded event types are translated for operators' +Assert-True ($source.Contains('GooseOpenConfigurationCommand')) 'Unidentified values provide a path to device configuration' +Assert-True ($source.Contains('Text="SIGNAL OR DATA ITEM"')) 'Unnamed raw data is explicitly identified' +Write-Host 'GOOSE operator language contract: PASS (plain labels + original wire evidence)' Assert-True (-not $inspector.Contains('')) 'DataSet title no longer uses ambiguous DockPanel filling' Write-Host 'GOOSE Inspector screenshot regression gate: PASS (fixed header, count alignment, compact MMS details)' Write-Host 'GOOSE readout contract: PASS (scoped chevron, RX/events, TEST, stable typed rows, live return)' From 32f9f4e711df989448c70cbb8a2d39e1c025ab4c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:55:19 +0700 Subject: [PATCH 068/102] docs(goose): electrical-engineer language guide, clock semantics and safe signal-identification acceptance --- ...SE_ENGINEERING_READOUT_P0_P3_2026-10-21.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/validation/GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md b/docs/validation/GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md index a9f4b581..597cebd8 100644 --- a/docs/validation/GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md +++ b/docs/validation/GOOSE_ENGINEERING_READOUT_P0_P3_2026-10-21.md @@ -46,3 +46,28 @@ Three operator screenshots of the P0–P3 candidate demonstrate selection stabil The targeted follow-up uses a fixed Inspector header row (including PINNED/TEST and Return to Live), a single scrolling evidence region, an explicitly aligned DataSet title/count grid, and compact key/value MMS details. These corrections do not alter packet reception, GOOSE state matching, generic typed decoding, or the 8-DIP workspace gutter. Required physical acceptance: expand two entries, scroll to the bottom and back, confirm the Inspector mode and Return to Live remain visible throughout; confirm the DataSet count does not touch the title at 100%, 125%, 150% DPI; verify selecting a different publisher or returning to Live changes Inspector context correctly without replacing unrelated publisher rows. + +## Operator language acceptance — message/field terminology + +The application must explain what an electrical engineer is seeing without requiring familiarity with every IEC 61850 field name. Keep accurate original wire references in tooltips or expandable forensic rows. + +| Main display | Original technical evidence | +| --- | --- | +| Message name | goID | +| Control block | goCbRef | +| Application code | APPID | +| State number / Sequence number | stNum / sqNum | +| Sender address / Group address | Ethernet source / destination MAC | +| Network group | VLAN ID and priority | +| Configuration version | confRev | +| Allowed wait | timeAllowedToLive, milliseconds | +| Binary bit pattern | MMS BitString | +| Data item 1, Data item 2… | Raw decoded values without matching SCL | +| Open/Closed (verified switch-position signal only) | Matched SCL XCBR or XSWI / Pos.stVal | +| First seen / State changed / Repeated | New / State Change / Retrans reception events | + +**Failure safety:** no breaker position can be inferred from a bare `00/01/10/11` bit pattern, unrelated DPC attributes, quality fields, timestamps or incomplete SCL identity. Source data is not renamed when evidence is missing. A configuration-tab shortcut explains how to identify signals. + +**Clock semantics:** the reception time in message history must use the same local clock as the recorded-message Inspector timestamp, avoiding false multi-hour offsets on site. + +**Additional physical acceptance:** use a multi-publisher real capture and a matching imported device configuration. Compare message history time against the Inspector time; validate names, interpreted switch position and decoded quality against a separately verified record. With no configuration, all entries remain generic with readable data type and raw bit/hex evidence. Test 100/125/150% scaling and expand two data rows without losing the sticky Inspector heading. From 56e8f3b818d925a0ca587079d0543c1daf8ff309 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:56:24 +0700 Subject: [PATCH 069/102] polish(goose): remove last sequence abbreviation from the sending-device card --- src/ProcessBus.App.Wpf/MainWindow.xaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 7e58c5c2..da713dbe 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1499,8 +1499,7 @@ FontSize="10.5" TextTrimming="CharacterEllipsis"> - - + From 16b9565829565d1ea352daad572c833334ed05fb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:56:56 +0700 Subject: [PATCH 070/102] test(goose): avoid test-framework overload ambiguity on safety assertions --- tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs b/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs index 61e6f097..d0c7b99b 100644 --- a/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs +++ b/tests/ProcessBus.Tests/GooseEngineeringLanguageTests.cs @@ -22,8 +22,8 @@ public void UnmappedBitStringMustNotBecomeBreakerOpenOrClose() { var display = GooseEngineeringLanguage.DataType("BitString"); Assert.Equal("Binary bit pattern", display); - Assert.DoesNotContain("OPEN", display, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("CLOSE", display, StringComparison.OrdinalIgnoreCase); + Assert.False(display.Contains("OPEN", StringComparison.OrdinalIgnoreCase)); + Assert.False(display.Contains("CLOSE", StringComparison.OrdinalIgnoreCase)); Assert.Equal("Data item 1", GooseEngineeringLanguage.DataItemName(1, null)); } From 9b4753ab7ca5b33b76a455ad4c28c41fee541dd3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:57:27 +0700 Subject: [PATCH 071/102] test(goose): lock local timestamp agreement and safe plain-language signal presentation --- scripts/verify-workspace-spacing.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index 858e892c..658c3e20 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -124,6 +124,13 @@ Assert-True ($source.Contains('Text="GOOSE Message History"')) 'History describe Assert-True ($source.Contains('Text="{Binding EventDescriptionText}"')) 'Recorded event types are translated for operators' Assert-True ($source.Contains('GooseOpenConfigurationCommand')) 'Unidentified values provide a path to device configuration' Assert-True ($source.Contains('Text="SIGNAL OR DATA ITEM"')) 'Unnamed raw data is explicitly identified' +$viewModelPath = Join-Path $Root 'src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs' +$vmSource = Get-Content -LiteralPath $viewModelPath -Raw +Assert-True ($vmSource.Contains('Source.LastSeenUtc.ToLocalTime().ToString("HH:mm:ss.fff")')) 'GOOSE timeline uses the same local clock as Inspector' +Assert-True ($vmSource.Contains('GooseEngineeringLanguage.TryDescribeDoublePoint')) 'Signal position labels use config-validated state interpretation' +Assert-True ($vmSource.Contains('GooseEngineeringLanguage.DataType')) 'Data type labels use human-friendly terminology' +Assert-True ($source.Contains('Text="Published Values"')) 'Signal table uses plain wording' +Assert-True ($source.Contains('Text="RECEIVED VALUE"')) 'Values remain clearly distinguished from interpretation' Write-Host 'GOOSE operator language contract: PASS (plain labels + original wire evidence)' Assert-True (-not $inspector.Contains('')) 'DataSet title no longer uses ambiguous DockPanel filling' Write-Host 'GOOSE Inspector screenshot regression gate: PASS (fixed header, count alignment, compact MMS details)' From dcb6724a47f87cac931cf3497fd56622b250a6eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:58:14 +0700 Subject: [PATCH 072/102] feat(goose): display commissioning and test flags as clear Yes/No statements --- .../ViewModels/MainWindowViewModel.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 2dea3540..6674c018 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -955,6 +955,18 @@ private void ReturnGooseToLive() public string GooseInspectorDatasetCountText => (GooseInspectorMessage?.DataValues.Count ?? 0).ToString(); + public string GooseInspectorFlagsText + { + get + { + var message = GooseInspectorMessage; + if (message is null) + return "No message selected"; + return $"Test message: {(message.IsTest ? "Yes" : "No")} · " + + $"Needs commissioning: {(message.NeedsCommission ? "Yes" : "No")}"; + } + } + public bool GooseSignalNamesUnavailable { get @@ -1044,6 +1056,7 @@ private void RaiseGooseInspectorProperties() OnPropertyChanged(nameof(SelectedGooseMessage)); OnPropertyChanged(nameof(GooseInspectorMessage)); OnPropertyChanged(nameof(GooseInspectorDatasetCountText)); + OnPropertyChanged(nameof(GooseInspectorFlagsText)); OnPropertyChanged(nameof(GooseInspectorModeText)); OnPropertyChanged(nameof(GooseInspectorModeBadgeText)); OnPropertyChanged(nameof(SelectedGooseDatasetValues)); From 9e7177422ba8280fb8dc0397539e542a50654b81 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 08:58:33 +0700 Subject: [PATCH 073/102] polish(goose-ui): explicit yes/no flags and readable configuration label --- src/ProcessBus.App.Wpf/MainWindow.xaml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index da713dbe..5ec68c58 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3368,19 +3368,13 @@ - + - - - - - - - - + From e21ecda0ca52ab4358784534f8bbd771c2693ce1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 09:01:54 +0700 Subject: [PATCH 074/102] test(goose-ui): track renamed plain-language heading without weakening fixed-header gate --- scripts/verify-workspace-spacing.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index 658c3e20..cc575a0e 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -107,7 +107,7 @@ $inspectorStart = $source.IndexOf(' + M15.5,14 H14.71 L14.43,13.73 A6.5,6.5 0 1 0 13.73,14.43 L14,14.71 V15.5 L19,20.49 L20.49,19 Z M10,14 A4,4 0 1 1 10,6 A4,4 0 0 1 10,14 Z + M19,6.41 L17.59,5 L12,10.59 L6.41,5 L5,6.41 L10.59,12 L5,17.59 L6.41,19 L12,13.41 L17.59,19 L19,17.59 L13.41,12 Z + M12,2 C6.48,2 2,6.48 2,12 C2,17.52 6.48,22 12,22 C17.52,22 22,17.52 22,12 C22,6.48 17.52,2 12,2 Z M12,20 C7.59,20 4,16.41 4,12 C4,7.59 7.59,4 12,4 C16.41,4 20,7.59 20,12 C20,16.41 16.41,20 12,20 Z M12,16 L6,10 L7.41,8.59 L12,13.17 L16.59,8.59 L18,10 Z + + + + + + + + + - - - - - @@ -1388,29 +1438,21 @@ - - - - - - - + + + - + + + + + @@ -3347,6 +3353,7 @@ + - - + + + + + @@ -3408,6 +3416,7 @@ Foreground="#DDEEFF" TextWrapping="Wrap" Margin="0,3,0,3"/> + - - - - - - - - - - - -