From 3f0df41f53ab7895db2d5d3643be703a905d87c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 05:24:10 +0700 Subject: [PATCH 1/6] fix(ux): route IED GOOSE CTA to subscriber and require adapter confirmation --- MainWindow.GooseSubscriber.cs | 10 +- MainWindow.IedGooseQuickStart.cs | 106 +++++++----------- .../FastWorkflowRegressionTests.cs | 20 +++- 3 files changed, 61 insertions(+), 75 deletions(-) diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 91c026f2b..7cd5b945c 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -251,11 +251,15 @@ private void RefreshGooseAdapters() var previousName = SelectedGooseAdapter?.Name; var adapters = _gooseSubscriberRuntime.ListAdapters(); GooseAdapters.ReplaceAll(adapters); + var usableAdapters = adapters.Where(adapter => !LooksLikeLoopback(adapter)).ToArray(); + // Never silently choose the first of several network interfaces: an + // unrelated LAN/Wi-Fi adapter can miss all IEC 61850 GOOSE frames. SelectedGooseAdapter = adapters.FirstOrDefault(adapter => - adapter.Name.Equals(previousName, StringComparison.OrdinalIgnoreCase)) ?? adapters.FirstOrDefault(); + adapter.Name.Equals(previousName, StringComparison.OrdinalIgnoreCase)) + ?? (usableAdapters.Length == 1 ? usableAdapters[0] : null); GooseStatusText = adapters.Count == 0 - ? "No Npcap/WinPcap adapters were found. Install Npcap and restart ArIED." - : $"{adapters.Count:N0} capture adapter(s) available. Select the approved station/LAN interface."; + ? "No Npcap/WinPcap adapters found. Install Npcap, then refresh adapters." + : $"{adapters.Count:N0} capture adapter(s) available. Choose the station Ethernet adapter and press Start."; } catch (Exception ex) { diff --git a/MainWindow.IedGooseQuickStart.cs b/MainWindow.IedGooseQuickStart.cs index b3366a0ae..13120669a 100644 --- a/MainWindow.IedGooseQuickStart.cs +++ b/MainWindow.IedGooseQuickStart.cs @@ -17,6 +17,7 @@ namespace ArIED61850Tester; public partial class MainWindow { private const string IedGooseQuickStartTag = "ARSAS_IED_GOOSE_QUICK_START"; + private const int GooseSubscriberTabIndex = 4; private static readonly bool IedGooseQuickStartRegistered = RegisterIedGooseQuickStart(); private static bool RegisterIedGooseQuickStart() @@ -78,7 +79,7 @@ private void InstallIedGooseQuickStart(UniformGrid actionGrid, Iec61850MonitorDe Height = 27, Margin = new Thickness(0), Cursor = System.Windows.Input.Cursors.Hand, - ToolTip = "Open GOOSE Subscriber and start capture on the Ethernet adapter routed to this IED", + ToolTip = "Open GOOSE Subscriber, choose the station Ethernet adapter, then press Start", Content = new Viewbox { Width = 14, @@ -90,87 +91,56 @@ private void InstallIedGooseQuickStart(UniformGrid actionGrid, Iec61850MonitorDe actionGrid.Children.Add(button); } - private async void IedGooseQuickStart_Click(object sender, RoutedEventArgs e) + private void IedGooseQuickStart_Click(object sender, RoutedEventArgs e) { if (sender is not Button { CommandParameter: Iec61850MonitorDevice device }) return; SelectedDevice = device; - MainTabs.SelectedIndex = 3; - UpdateNavigationVisuals(3, animate: true); + // Workspace indices: Explorer=0, Live=1, Events=2, Alarm=3, GOOSE=4. + // This CTA is navigation + adapter recommendation, never a hidden Start. + MainTabs.SelectedIndex = GooseSubscriberTabIndex; + UpdateNavigationVisuals(GooseSubscriberTabIndex, animate: true); ActivateGooseSubscriberWorkspace(); - await Dispatcher.Yield(DispatcherPriority.Loaded); - if (GooseAdapters.Count == 0) - RefreshGooseAdapters(); - - var adapter = ResolveGooseAdapterForIed(device, out var routeDetail); - if (adapter is null) + // First render the GOOSE tab; Npcap enumeration is optional and can fail. + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => { - GooseStatusText = - $"GOOSE workspace opened for {device.Name}, but ARSAS could not identify one unique capture adapter for route {device.IpAddress}. Select the station-LAN adapter and press Start."; - SetStatus($"GOOSE: adapter selection required for {device.Name}."); - AddLog("WARN", "GOOSE", $"Automatic adapter selection failed for {device.Name} ({device.IpAddress}). {routeDetail}"); - return; - } + if (Dispatcher.HasShutdownStarted || MainTabs.SelectedIndex != GooseSubscriberTabIndex) + return; - await StartGooseForIedAsync(device, adapter, routeDetail); - } + if (GooseAdapters.Count == 0) + RefreshGooseAdapters(); - private async Task StartGooseForIedAsync( - Iec61850MonitorDevice device, - GooseAdapterOption adapter, - string routeDetail) - { - if (GooseActionBusy) - return; + // Do not stop/rebind an ongoing capture when the operator clicks a + // different IED card. The active capture adapter remains authoritative. + if (IsGooseCapturing || GooseActionBusy) + { + GooseStatusText = IsGooseCapturing + ? $"GOOSE capture already running on {SelectedGooseAdapter?.DisplayText ?? "the active adapter"}. Stop before changing adapters. Captured publishers remain visible." + : "GOOSE capture is busy. Wait until it finishes before changing adapters."; + return; + } - if (IsGooseCapturing && - SelectedGooseAdapter is not null && - SelectedGooseAdapter.Name.Equals(adapter.Name, StringComparison.OrdinalIgnoreCase)) - { + // Unicast MMS routing is only a suggestion for multicast L2 GOOSE. + // It cannot prove station-LAN, switch mirror or VLAN placement. + var adapter = ResolveGooseAdapterForIed(device, out var routeDetail); SelectedGooseAdapter = adapter; - GooseStatusText = - $"LIVE on {adapter.DisplayText} · selected from {device.Name} route {device.IpAddress} · capture already running."; - SetStatus($"GOOSE Subscriber already live for {device.Name} on the routed Ethernet adapter."); - return; - } - - if (IsGooseCapturing) - await StopGooseSubscriberAsync(); - - SelectedGooseAdapter = adapter; - GooseActionBusy = true; - try - { - RefreshGooseBindingPreview(); - ResetGooseView(resetCounters: true); - await _gooseSubscriberRuntime.StartAsync( - adapter.Selector, - _gooseBindingCatalog.SclDocument, - GooseCaptureFilter, - _applicationCancellation.Token); + if (adapter is null) + { + GooseStatusText = GooseAdapters.Count == 0 + ? "No capture adapters available. Install or enable Npcap, then refresh the adapter list." + : $"Choose the station Ethernet adapter for {device.Name}, then press Start. MMS IP routing alone cannot determine the GOOSE LAN."; + SetStatus($"GOOSE: select a capture adapter for {device.Name}."); + AddLog("INFO", "GOOSE", $"Manual adapter selection requested for {device.Name}: {routeDetail}"); + return; + } - IsGooseCapturing = true; GooseStatusText = - $"Listening on {adapter.DisplayText} for {device.Name} ({device.IpAddress}). Waiting for GOOSE frames…"; - SetStatus($"GOOSE Subscriber started for {device.Name} on its routed Ethernet adapter."); - AddLog( - "INFO", - "GOOSE", - $"One-click subscriber started for {device.Name} ({device.IpAddress}) on adapter {adapter.Index}: {adapter.Description}. Route selection: {routeDetail}. Binding: {_gooseBindingCatalog.Summary}"); - } - catch (Exception ex) - { - IsGooseCapturing = false; - GooseStatusText = $"Could not start GOOSE subscriber for {device.Name}: {DescribeGooseFailure(ex)}"; - AddLog("ERROR", "GOOSE", GooseStatusText); - MarkDiagnosticAlert(); - } - finally - { - GooseActionBusy = false; - } + $"Suggested adapter: {adapter.DisplayText} for {device.Name}. Confirm station-LAN/VLAN and press Start to monitor GOOSE frames."; + SetStatus($"GOOSE: confirm adapter and press Start for {device.Name}."); + AddLog("INFO", "GOOSE", $"Capture adapter suggested, not started: {routeDetail}"); + })); } private GooseAdapterOption? ResolveGooseAdapterForIed( diff --git a/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs b/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs index 4610c36ff..ab96b32d5 100644 --- a/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs +++ b/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs @@ -3,14 +3,26 @@ namespace ARSAS.Tests; public sealed class FastWorkflowRegressionTests { [Fact] - public void IedCardQuickCapture_UsesSelectedEndpointRoute() + public void IedCardGooseCta_OpensSubscriberAndWaitsForOperatorStart() { var source = File.ReadAllText(FindRepoFile("MainWindow.IedGooseQuickStart.cs")); - Assert.Contains("MainTabs.SelectedIndex = 3", source, StringComparison.Ordinal); + var subscriber = File.ReadAllText(FindRepoFile("MainWindow.GooseSubscriber.cs")); + var view = File.ReadAllText(FindRepoFile("Views/GooseSubscriberView.xaml")); + + Assert.Contains("GooseSubscriberTabIndex = 4", source, StringComparison.Ordinal); + Assert.Contains("MainTabs.SelectedIndex = GooseSubscriberTabIndex", source, StringComparison.Ordinal); + Assert.Contains("UpdateNavigationVisuals(GooseSubscriberTabIndex", source, StringComparison.Ordinal); + Assert.DoesNotContain("MainTabs.SelectedIndex = 3", source, StringComparison.Ordinal); Assert.Contains("ResolveLocalIpv4ForTarget", source, StringComparison.Ordinal); Assert.Contains("CaptureAdapterMatchesNetworkInterface", source, StringComparison.Ordinal); - Assert.Contains("_gooseSubscriberRuntime.StartAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("GooseAdapters.First()", source, StringComparison.Ordinal); + Assert.Contains("SelectedGooseAdapter = adapter", source, StringComparison.Ordinal); + Assert.Contains("if (IsGooseCapturing || GooseActionBusy)", source, StringComparison.Ordinal); + Assert.Contains("press Start", source, StringComparison.Ordinal); + Assert.DoesNotContain("_gooseSubscriberRuntime.StartAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("StopGooseSubscriberAsync()", source, StringComparison.Ordinal); + Assert.Contains("usableAdapters.Length == 1 ? usableAdapters[0] : null", subscriber, StringComparison.Ordinal); + Assert.Contains("Click=\"StartCapture_Click\"", view, StringComparison.Ordinal); + Assert.Contains("SelectedItem=\"{Binding SelectedGooseAdapter, Mode=TwoWay}\"", view, StringComparison.Ordinal); } [Fact] From c3d0271dd0447ad470b0d058242d32e93332d1f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 06:05:33 +0700 Subject: [PATCH 2/6] fix(workflow): route actual GOOSE pill correctly, start after explicit NIC selection, declutter command dock --- FaultRecordUxBehavior.cs | 6 +- MainWindow.GooseSubscriber.cs | 22 ++++++++ MainWindow.IedGooseQuickStart.cs | 56 ++++++++----------- MainWindow.xaml | 32 +---------- .../FastWorkflowRegressionTests.cs | 36 ++++++------ 5 files changed, 69 insertions(+), 83 deletions(-) diff --git a/FaultRecordUxBehavior.cs b/FaultRecordUxBehavior.cs index 4f29dbaa0..97c1f68b7 100644 --- a/FaultRecordUxBehavior.cs +++ b/FaultRecordUxBehavior.cs @@ -268,9 +268,9 @@ private static void ApplyState(Button button, CapabilityState state, string tool private static void OpenGooseWorkspace(MainWindow mainWindow, Iec61850MonitorDevice device) { - mainWindow.SelectedDevice = device; - if (mainWindow.FindName("MainTabs") is TabControl tabs) - tabs.SelectedIndex = 3; + // The visible GOOSE capability pill is the operator's CTA. Route through + // the same adapter-pick workflow as any other GOOSE shortcut. + mainWindow.OpenIedGooseSubscriber(device); mainWindow.Activate(); } diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 7cd5b945c..c0be207f7 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -21,6 +21,8 @@ public partial class MainWindow private readonly Dictionary _gooseStreamIndex = new(StringComparer.OrdinalIgnoreCase); private GooseBindingCatalog _gooseBindingCatalog = GooseBindingCatalog.Empty; private GooseAdapterOption? _selectedGooseAdapter; + // Armed only by an IED-card CTA. Direct GOOSE tab navigation remains manual. + private Iec61850MonitorDevice? _pendingIedGooseAutoStart; private GooseStreamRow? _selectedGooseStream; private bool _isGooseCapturing; private bool _gooseActionBusy; @@ -44,6 +46,25 @@ public GooseAdapterOption? SelectedGooseAdapter if (!Set(ref _selectedGooseAdapter, value)) return; Raise(nameof(CanStartGooseSubscriber)); Raise(nameof(SelectedGooseAdapterDetail)); + if (value is not null && _pendingIedGooseAutoStart is not null && + MainTabs.SelectedIndex == GooseSubscriberTabIndex && + !IsGooseCapturing && !GooseActionBusy) + { + // Consume the one-shot intent before dispatching: repeated selection + // notifications can never start two capture sessions. + _pendingIedGooseAutoStart = null; + var chosen = value; + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (!Dispatcher.HasShutdownStarted && + MainTabs.SelectedIndex == GooseSubscriberTabIndex && + ReferenceEquals(SelectedGooseAdapter, chosen) && + !IsGooseCapturing && !GooseActionBusy) + { + StartGooseSubscriber_Click(this, new RoutedEventArgs()); + } + })); + } } } @@ -154,6 +175,7 @@ private void RefreshGooseModels_Click(object sender, RoutedEventArgs e) private async void StartGooseSubscriber_Click(object sender, RoutedEventArgs e) { + _pendingIedGooseAutoStart = null; if (SelectedGooseAdapter is null || GooseActionBusy || IsGooseCapturing) return; diff --git a/MainWindow.IedGooseQuickStart.cs b/MainWindow.IedGooseQuickStart.cs index 13120669a..0b8fc1c7b 100644 --- a/MainWindow.IedGooseQuickStart.cs +++ b/MainWindow.IedGooseQuickStart.cs @@ -93,53 +93,45 @@ private void InstallIedGooseQuickStart(UniformGrid actionGrid, Iec61850MonitorDe private void IedGooseQuickStart_Click(object sender, RoutedEventArgs e) { - if (sender is not Button { CommandParameter: Iec61850MonitorDevice device }) - return; + if (sender is Button { CommandParameter: Iec61850MonitorDevice device }) + OpenIedGooseSubscriber(device); + } + /// + /// Shared entry point for the visible IED-card GOOSE capability pill and + /// the legacy icon. The UI must show GOOSE before touching optional Npcap. + /// The operator's next adapter selection starts capture exactly once. + /// Direct navigation to the GOOSE tab preserves its manual Start workflow. + /// + internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device) + { SelectedDevice = device; - // Workspace indices: Explorer=0, Live=1, Events=2, Alarm=3, GOOSE=4. - // This CTA is navigation + adapter recommendation, never a hidden Start. + _pendingIedGooseAutoStart = null; MainTabs.SelectedIndex = GooseSubscriberTabIndex; UpdateNavigationVisuals(GooseSubscriberTabIndex, animate: true); ActivateGooseSubscriberWorkspace(); - - // First render the GOOSE tab; Npcap enumeration is optional and can fail. Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => { if (Dispatcher.HasShutdownStarted || MainTabs.SelectedIndex != GooseSubscriberTabIndex) return; - - if (GooseAdapters.Count == 0) - RefreshGooseAdapters(); - - // Do not stop/rebind an ongoing capture when the operator clicks a - // different IED card. The active capture adapter remains authoritative. if (IsGooseCapturing || GooseActionBusy) { GooseStatusText = IsGooseCapturing - ? $"GOOSE capture already running on {SelectedGooseAdapter?.DisplayText ?? "the active adapter"}. Stop before changing adapters. Captured publishers remain visible." - : "GOOSE capture is busy. Wait until it finishes before changing adapters."; - return; - } - - // Unicast MMS routing is only a suggestion for multicast L2 GOOSE. - // It cannot prove station-LAN, switch mirror or VLAN placement. - var adapter = ResolveGooseAdapterForIed(device, out var routeDetail); - SelectedGooseAdapter = adapter; - if (adapter is null) - { - GooseStatusText = GooseAdapters.Count == 0 - ? "No capture adapters available. Install or enable Npcap, then refresh the adapter list." - : $"Choose the station Ethernet adapter for {device.Name}, then press Start. MMS IP routing alone cannot determine the GOOSE LAN."; - SetStatus($"GOOSE: select a capture adapter for {device.Name}."); - AddLog("INFO", "GOOSE", $"Manual adapter selection requested for {device.Name}: {routeDetail}"); + ? "GOOSE monitoring is already running. Stop it before changing network adapters." + : "GOOSE capture is busy. Wait for the current operation."; return; } + if (GooseAdapters.Count == 0) + RefreshGooseAdapters(); - GooseStatusText = - $"Suggested adapter: {adapter.DisplayText} for {device.Name}. Confirm station-LAN/VLAN and press Start to monitor GOOSE frames."; - SetStatus($"GOOSE: confirm adapter and press Start for {device.Name}."); - AddLog("INFO", "GOOSE", $"Capture adapter suggested, not started: {routeDetail}"); + // Never assume the unicast MMS route is the GOOSE L2 network. The + // Npcap adapter must be explicitly selected before capture begins. + SelectedGooseAdapter = null; + _pendingIedGooseAutoStart = device; + GooseStatusText = GooseAdapters.Count == 0 + ? "No Ethernet capture adapter found. Check Npcap and refresh the adapter list." + : $"Select the station network adapter for {device.Name}. GOOSE capture starts automatically after selection."; + SetStatus($"GOOSE: select network adapter for {device.Name}."); })); } diff --git a/MainWindow.xaml b/MainWindow.xaml index 6482c8cfc..678c69e82 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -745,37 +745,7 @@ - - - - - - - - - - - - - - - - - - + Date: Sat, 10 Oct 2026 06:08:37 +0700 Subject: [PATCH 3/6] feat(goose): focus message inspector, filter stable retransmissions, show changed values for 3s --- MainWindow.GooseTimeline.cs | 102 +++++++++--------- Models/GoosePresentationModels.cs | 2 +- Models/GooseSubscriberModels.cs | 9 +- Services/GooseTimelineEventPolicy.cs | 22 ++++ Views/GooseSubscriberLiteView.xaml | 25 ++--- .../GooseTimelineEventPolicyTests.cs | 59 ++++++++++ 6 files changed, 152 insertions(+), 67 deletions(-) create mode 100644 Services/GooseTimelineEventPolicy.cs create mode 100644 tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index b1c48fe1f..014f2ec0f 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -12,10 +12,18 @@ namespace ArIED61850Tester; public partial class MainWindow { - private const int MaxGooseTimelineEvents = 300; + private const int MaxGooseTimelineEvents = 500; private const int MaxPendingGooseTimelineEvents = 512; - private readonly ConcurrentQueue _pendingGooseTimeline = new(); + private readonly ConcurrentQueue<(GooseSubscriberFrameSnapshot Frame, GooseTimelineDecision Decision)> _pendingGooseTimeline = new(); + private readonly Dictionary _lastGooseTimelineSignature = new(StringComparer.OrdinalIgnoreCase); + private readonly object _gooseTimelineGate = new(); + private bool _showGooseRetransmissions; + public bool ShowGooseRetransmissions + { + get => _showGooseRetransmissions; + set => Set(ref _showGooseRetransmissions, value); + } private readonly Dictionary _lastGooseTimelineTimestamp = new(StringComparer.OrdinalIgnoreCase); private GooseEventRow? _selectedGooseEvent; private bool _goosePresentationInstalled; @@ -143,26 +151,29 @@ private static void OnClearGooseRequested(object sender, RoutedEventArgs args) args.Handled = true; } - private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot snapshot) + private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot frame) { - if (!IsMeaningfulGooseTimelineEvent(snapshot)) - return; - - _pendingGooseTimeline.Enqueue(snapshot); - while (_pendingGooseTimeline.Count > MaxPendingGooseTimelineEvents && _pendingGooseTimeline.TryDequeue(out _)) + // Read-only capture still records every frame. Only operator messages + // are coalesced, using exact decoded payload evidence per publisher. + var values = frame.StreamEvent.GooseValues.Count > 0 + ? frame.StreamEvent.GooseValues.Select(x => x.DisplayValue) + : frame.Frame.Pdu.Values.Select(x => MmsDataValueRenderer.ToCompactString(x, string.Empty)); + var fingerprint = new GooseTimelineSignature( + string.Concat(values.Select(x => $"{x?.Length ?? 0}:{x}")), + frame.Frame.Pdu.StateNumber.ToString(CultureInfo.InvariantCulture), + string.Join(" • ", frame.StreamEvent.Diagnostics.OrderBy(x => x, StringComparer.Ordinal)), + frame.StreamEvent.GooseSequenceStatus.ToString()); + GooseTimelineDecision decision; + lock (_gooseTimelineGate) { + _lastGooseTimelineSignature.TryGetValue(frame.StreamKey, out var previous); + decision = GooseTimelineEventPolicy.Evaluate(previous, fingerprint, ShowGooseRetransmissions); + _lastGooseTimelineSignature[frame.StreamKey] = fingerprint; } - } - - private static bool IsMeaningfulGooseTimelineEvent(GooseSubscriberFrameSnapshot snapshot) - { - if (snapshot.PacketCount <= 1 || snapshot.StreamEvent.ChangedValueCount > 0 || snapshot.StreamEvent.Diagnostics.Count > 0) - return true; - - var status = snapshot.StreamEvent.GooseSequenceStatus.ToString(); - return status.Contains("State", StringComparison.OrdinalIgnoreCase) || - (!status.Contains("Retransmission", StringComparison.OrdinalIgnoreCase) && - !status.Contains("Normal", StringComparison.OrdinalIgnoreCase)); + if (!decision.Include) return; + _pendingGooseTimeline.Enqueue((frame, decision)); + while (_pendingGooseTimeline.Count > MaxPendingGooseTimelineEvents && + _pendingGooseTimeline.TryDequeue(out _)) { } } private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args) @@ -171,7 +182,7 @@ private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args) if (nowUtc >= _nextGooseHighlightExpiryCheckUtc) { ExpireGooseHighlights(nowUtc); - _nextGooseHighlightExpiryCheckUtc = nowUtc.AddSeconds(1); + _nextGooseHighlightExpiryCheckUtc = nowUtc.AddMilliseconds(250); } if (_pendingGooseTimeline.IsEmpty) @@ -180,8 +191,8 @@ private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args) var processed = 0; while (processed < 48 && _pendingGooseTimeline.TryDequeue(out var captured)) { - var stream = BuildGooseStreamSnapshot(captured, _gooseBindingCatalog); - var eventRow = BuildGooseEventRow(captured, stream); + var stream = BuildGooseStreamSnapshot(captured.Frame, _gooseBindingCatalog); + var eventRow = BuildGooseEventRow(captured.Frame, stream, captured.Decision); // Append in capture order. Tail insertion avoids shifting every realized row. GooseEvents.Add(eventRow); while (GooseEvents.Count > MaxGooseTimelineEvents) @@ -206,46 +217,34 @@ private void ExpireGooseHighlights(DateTimeOffset nowUtc) } } - private GooseEventRow BuildGooseEventRow( - GooseSubscriberFrameSnapshot captured, - GooseStreamSnapshot stream) + private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, + GooseStreamSnapshot stream, GooseTimelineDecision decision) { - var status = stream.SequenceStatus; - var hasDiagnostics = !string.IsNullOrWhiteSpace(stream.DiagnosticsSummary); - var isNew = captured.PacketCount <= 1; - var isStateChange = stream.ChangedValueCount > 0 || status.Contains("State", StringComparison.OrdinalIgnoreCase); - - var eventText = isNew - ? "New" - : hasDiagnostics - ? "Warning" - : isStateChange - ? "State change" - : FriendlySequenceStatus(status); - var eventTone = hasDiagnostics - ? "Warning" - : isStateChange - ? "Change" - : "Info"; - + var hasNewWarning = !string.IsNullOrWhiteSpace(stream.DiagnosticsSummary) && + (decision.IsNew || decision.DiagnosticsChanged); + var valueAnomaly = decision.PayloadChanged && !decision.StateChanged; + var changed = decision.PayloadChanged || decision.StateChanged; + var eventText = decision.IsNew ? "New" : + valueAnomaly ? "Value changed" : + hasNewWarning || decision.NewSequenceAnomaly ? "Warning" : + changed ? "State change" : FriendlySequenceStatus(stream.SequenceStatus); + var tone = valueAnomaly || hasNewWarning || decision.NewSequenceAnomaly ? "Warning" : + changed ? "Change" : "Info"; var deltaText = "-"; - if (_lastGooseTimelineTimestamp.TryGetValue(captured.StreamKey, out var previousTimestamp)) - { - var delta = captured.CaptureTimestamp - previousTimestamp; - deltaText = FormatGooseDelta(delta); - } + if (_lastGooseTimelineTimestamp.TryGetValue(captured.StreamKey, out var prev)) + deltaText = FormatGooseDelta(captured.CaptureTimestamp - prev); _lastGooseTimelineTimestamp[captured.StreamKey] = captured.CaptureTimestamp; - return new GooseEventRow { StreamKey = captured.StreamKey, Timestamp = captured.CaptureTimestamp, DeltaText = deltaText, EventText = eventText, - EventTone = eventTone, + EventTone = tone, Publisher = BuildGoosePublisherName(stream), StateSequenceText = $"{stream.StateNumberText} / {stream.SequenceNumberText}", - Summary = BuildGooseEventSummary(stream, isNew, hasDiagnostics) + Summary = valueAnomaly ? "Payload changed without stNum increment — verify publisher." : + BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning) }; } @@ -345,6 +344,7 @@ private void ResetGooseTimelineUi() { } _lastGooseTimelineTimestamp.Clear(); + lock (_gooseTimelineGate) _lastGooseTimelineSignature.Clear(); _nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue; GooseEvents.Clear(); SelectedGooseEvent = null; diff --git a/Models/GoosePresentationModels.cs b/Models/GoosePresentationModels.cs index 5e4e04abe..5e9d05607 100644 --- a/Models/GoosePresentationModels.cs +++ b/Models/GoosePresentationModels.cs @@ -78,7 +78,7 @@ public sealed class GooseEventRow : ObservableObject public bool ExpireHighlight(DateTimeOffset nowUtc) { - if (!IsRecent || nowUtc - Timestamp < TimeSpan.FromSeconds(5)) + if (!IsRecent || nowUtc - Timestamp < TimeSpan.FromSeconds(3)) return false; IsRecent = false; return true; diff --git a/Models/GooseSubscriberModels.cs b/Models/GooseSubscriberModels.cs index b5bcfaa53..54e670a0f 100644 --- a/Models/GooseSubscriberModels.cs +++ b/Models/GooseSubscriberModels.cs @@ -55,13 +55,16 @@ public void Apply(GooseLeafValueSnapshot snapshot) FunctionalConstraint = snapshot.FunctionalConstraint; Cdc = snapshot.Cdc; BType = snapshot.BType; + var changedOnWire = _value != "-" && + !string.Equals(_value, snapshot.Value, StringComparison.Ordinal) && + !string.Equals(snapshot.Value, "", StringComparison.Ordinal); PreviousValue = snapshot.PreviousValue; Value = snapshot.Value; BindingSource = snapshot.BindingSource; - IsChanged = snapshot.IsChanged; - if (snapshot.IsChanged) + IsChanged = snapshot.IsChanged || changedOnWire; + if (IsChanged) { - _highlightUntilUtc = DateTimeOffset.UtcNow.AddSeconds(5); + _highlightUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3); IsHighlighted = true; } Raise(nameof(TypeText)); diff --git a/Services/GooseTimelineEventPolicy.cs b/Services/GooseTimelineEventPolicy.cs new file mode 100644 index 000000000..3313057ae --- /dev/null +++ b/Services/GooseTimelineEventPolicy.cs @@ -0,0 +1,22 @@ +namespace ArIED61850Tester.Services; + +public sealed record GooseTimelineSignature(string Payload, string State, string Diagnostics, string Sequence); +public readonly record struct GooseTimelineDecision(bool Include, bool IsNew, bool PayloadChanged, bool StateChanged, bool DiagnosticsChanged, bool NewSequenceAnomaly); + +/// Presentation-only filter. All packets still enter the read-only wire capture. +public static class GooseTimelineEventPolicy +{ + public static GooseTimelineDecision Evaluate(GooseTimelineSignature? previous, GooseTimelineSignature current, bool showRetransmissions) + { + ArgumentNullException.ThrowIfNull(current); + var first = previous is null; + var valueChanged = !first && previous!.Payload != current.Payload; + var stateChanged = !first && previous!.State != current.State; + var diagnosticChanged = !first && previous!.Diagnostics != current.Diagnostics; + var normal = current.Sequence.Contains("Retransmission", StringComparison.OrdinalIgnoreCase) || + current.Sequence.Contains("Normal", StringComparison.OrdinalIgnoreCase); + var newAnomaly = !first && previous!.Sequence != current.Sequence && !normal; + return new GooseTimelineDecision(first || valueChanged || stateChanged || diagnosticChanged || newAnomaly || showRetransmissions, + first, valueChanged, stateChanged, diagnosticChanged, newAnomaly); + } +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 53bae9d2a..87692df61 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -153,14 +153,12 @@ - + - - - + - + @@ -279,7 +277,7 @@ - + @@ -292,7 +290,7 @@ - @@ -300,8 +298,11 @@ FontWeight="SemiBold" Foreground="#45648E"/> - + @@ -372,7 +373,7 @@ - + @@ -382,7 +383,7 @@ - @@ -391,7 +392,7 @@ - + diff --git a/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs new file mode 100644 index 000000000..64f6e4c34 --- /dev/null +++ b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs @@ -0,0 +1,59 @@ +using ArIED61850Tester.Services; +namespace ARSAS.Tests; + +public sealed class GooseTimelineEventPolicyTests +{ + [Fact] + public void NormalRetransmissionWithPersistentTestWarning_DoesNotRepeatInMessages() + { + var first = new GooseTimelineSignature("intermediate|false", "1", "GOOSE test flag is set.", "New"); + var retrans = first with { Sequence = "Retransmission" }; + Assert.True(GooseTimelineEventPolicy.Evaluate(null, first, false).Include); + Assert.False(GooseTimelineEventPolicy.Evaluate(first, retrans, false).Include); + Assert.False(GooseTimelineEventPolicy.Evaluate(retrans, retrans, false).Include); + Assert.True(GooseTimelineEventPolicy.Evaluate(retrans, retrans, true).Include); + } + + [Fact] + public void UnexpectedPayloadChangeWithoutStateIncrement_IsVisible() + { + var first = new GooseTimelineSignature("intermediate|false", "1", "", "Retransmission"); + var changed = first with { Payload = "closed|false" }; + var result = GooseTimelineEventPolicy.Evaluate(first, changed, false); + Assert.True(result.Include); + Assert.True(result.PayloadChanged); + Assert.False(result.StateChanged); + } + + [Fact] + public void ChangedStateAndNewDiagnostic_AreVisible() + { + var first = new GooseTimelineSignature("intermediate", "1", "", "Normal"); + Assert.True(GooseTimelineEventPolicy.Evaluate(first, first with { State = "2" }, false).Include); + Assert.True(GooseTimelineEventPolicy.Evaluate(first, first with { Diagnostics = "Bad confRev" }, false).Include); + } + + [Fact] + public void CurrentGooseUi_HasThreeSecondHighlightAndOperatorRetransmissionToggle() + { + var view = Read("Views/GooseSubscriberLiteView.xaml"); + var timeline = Read("MainWindow.GooseTimeline.cs"); + var leaves = Read("Models/GooseSubscriberModels.cs"); + Assert.Contains("ShowGooseRetransmissions", view); + Assert.Contains("GooseTimelineEventPolicy.Evaluate", timeline); + Assert.Contains("TimeSpan.FromSeconds(3)", leaves); + Assert.DoesNotContain("IsMeaningfulGooseTimelineEvent", timeline); + } + + private static string Read(string path) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, path.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(candidate)) return File.ReadAllText(candidate); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +} From 1cb07b34894e71523340527b3081d207ba297340 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 06:12:00 +0700 Subject: [PATCH 4/6] fix(goose): require operator-confirmed NIC for one-shot auto start and permit unbound listening --- FaultRecordUxBehavior.cs | 6 ++-- MainWindow.GooseSubscriber.cs | 35 +++++++++---------- MainWindow.GooseTimeline.cs | 12 +++++++ MainWindow.xaml.cs | 4 +++ Views/GooseSubscriberLiteView.xaml | 2 ++ Views/GooseSubscriberLiteView.xaml.cs | 18 ++++++++++ .../FastWorkflowRegressionTests.cs | 6 ++++ 7 files changed, 61 insertions(+), 22 deletions(-) diff --git a/FaultRecordUxBehavior.cs b/FaultRecordUxBehavior.cs index 97c1f68b7..e16d42d3e 100644 --- a/FaultRecordUxBehavior.cs +++ b/FaultRecordUxBehavior.cs @@ -211,10 +211,10 @@ private static void RefreshCapabilityState( ApplyState( gooseButton, - hasGoose ? CapabilityState.Available : CapabilityState.Unavailable, + hasGoose ? CapabilityState.Available : CapabilityState.ProbeReady, hasGoose - ? $"Open GOOSE Subscriber for {device.Name}" - : $"{device.Name} has no configured or discovered GOOSE control block"); + ? $"Monitor IEC 61850 GOOSE frames for {device.Name}" + : $"Monitor raw GOOSE frames for {device.Name} (no GoCB model configured)"); ApplyState( smvButton, hasSmv ? CapabilityState.Available : CapabilityState.Unavailable, diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index c0be207f7..3a3312e91 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -46,25 +46,7 @@ public GooseAdapterOption? SelectedGooseAdapter if (!Set(ref _selectedGooseAdapter, value)) return; Raise(nameof(CanStartGooseSubscriber)); Raise(nameof(SelectedGooseAdapterDetail)); - if (value is not null && _pendingIedGooseAutoStart is not null && - MainTabs.SelectedIndex == GooseSubscriberTabIndex && - !IsGooseCapturing && !GooseActionBusy) - { - // Consume the one-shot intent before dispatching: repeated selection - // notifications can never start two capture sessions. - _pendingIedGooseAutoStart = null; - var chosen = value; - Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => - { - if (!Dispatcher.HasShutdownStarted && - MainTabs.SelectedIndex == GooseSubscriberTabIndex && - ReferenceEquals(SelectedGooseAdapter, chosen) && - !IsGooseCapturing && !GooseActionBusy) - { - StartGooseSubscriber_Click(this, new RoutedEventArgs()); - } - })); - } + } } @@ -173,6 +155,21 @@ private void RefreshGooseAdapters_Click(object sender, RoutedEventArgs e) private void RefreshGooseModels_Click(object sender, RoutedEventArgs e) => RefreshGooseBindingPreview(); + /// + /// The IED-card intent is consumed only by an operator adapter choice. + /// Refresh or a programmatic ComboBox binding cannot auto-start capture. + /// + internal void ConfirmIedGooseAdapterSelection() + { + if (_pendingIedGooseAutoStart is null || SelectedGooseAdapter is null || + MainTabs.SelectedIndex != GooseSubscriberTabIndex || + IsGooseCapturing || GooseActionBusy) + return; + _pendingIedGooseAutoStart = null; + ResetGooseTimelineUi(); + StartGooseSubscriber_Click(this, new RoutedEventArgs()); + } + private async void StartGooseSubscriber_Click(object sender, RoutedEventArgs e) { _pendingIedGooseAutoStart = null; diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 014f2ec0f..d302faa23 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -64,6 +64,10 @@ internal static void RegisterGoosePresentationIntegration() typeof(MainWindow), GooseSubscriberLiteView.RefreshModelsRequestedEvent, new RoutedEventHandler(OnRefreshGooseModelsRequested)); + EventManager.RegisterClassHandler( + typeof(MainWindow), + GooseSubscriberLiteView.AdapterConfirmedEvent, + new RoutedEventHandler(OnGooseAdapterConfirmed)); EventManager.RegisterClassHandler( typeof(MainWindow), GooseSubscriberLiteView.StartRequestedEvent, @@ -125,6 +129,14 @@ private static void OnRefreshGooseModelsRequested(object sender, RoutedEventArgs args.Handled = true; } + private static void OnGooseAdapterConfirmed(object sender, RoutedEventArgs args) + { + if (sender is not MainWindow window) + return; + window.ConfirmIedGooseAdapterSelection(); + args.Handled = true; + } + private static void OnStartGooseRequested(object sender, RoutedEventArgs args) { if (sender is not MainWindow window) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index fb24c3a7f..c2629ba5a 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -798,6 +798,10 @@ private void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs if (!ReferenceEquals(e.Source, MainTabs)) return; + // Leaving the picker cancels the one-shot adapter-start intent. + if (MainTabs.SelectedIndex != GooseSubscriberTabIndex) + _pendingIedGooseAutoStart = null; + if (MainTabs.SelectedIndex == 2) { foreach (var device in Devices) diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 87692df61..da71ca7f8 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -51,6 +51,8 @@ RemoveHandler(RefreshModelsRequestedEvent, value); } + public event RoutedEventHandler AdapterConfirmed + { + add => AddHandler(AdapterConfirmedEvent, value); + remove => RemoveHandler(AdapterConfirmedEvent, value); + } + public event RoutedEventHandler StartRequested { add => AddHandler(StartRequestedEvent, value); @@ -55,6 +64,15 @@ public event RoutedEventHandler ClearRequested remove => RemoveHandler(ClearRequestedEvent, value); } + private void AdapterComboBox_DropDownClosed(object sender, EventArgs e) + => RaiseEvent(new RoutedEventArgs(AdapterConfirmedEvent, this)); + + private void AdapterComboBox_KeyUp(object sender, System.Windows.Input.KeyEventArgs e) + { + if (e.Key == System.Windows.Input.Key.Return && sender is ComboBox { IsDropDownOpen: false }) + RaiseEvent(new RoutedEventArgs(AdapterConfirmedEvent, this)); + } + private void RefreshAdapters_Click(object sender, RoutedEventArgs e) => RaiseEvent(new RoutedEventArgs(RefreshAdaptersRequestedEvent, this)); diff --git a/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs b/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs index dc75cbc9b..84e26b381 100644 --- a/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs +++ b/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs @@ -17,7 +17,13 @@ public void VisibleGooseCapabilityPill_OpensCorrectTabAndArmsOneShotAdapterAutoS Assert.Contains("OpenIedGooseSubscriber(device)", quick, StringComparison.Ordinal); Assert.Contains("SelectedGooseAdapter = null;", quick, StringComparison.Ordinal); Assert.Contains("_pendingIedGooseAutoStart = device;", quick, StringComparison.Ordinal); + var liteView = File.ReadAllText(FindRepoFile("Views/GooseSubscriberLiteView.xaml")); + var viewCode = File.ReadAllText(FindRepoFile("Views/GooseSubscriberLiteView.xaml.cs")); Assert.Contains("StartGooseSubscriber_Click(this, new RoutedEventArgs())", subscriber, StringComparison.Ordinal); + Assert.Contains("ConfirmIedGooseAdapterSelection()", subscriber, StringComparison.Ordinal); + Assert.Contains("AdapterConfirmedEvent", viewCode, StringComparison.Ordinal); + Assert.Contains("DropDownClosed=\"AdapterComboBox_DropDownClosed\"", liteView, StringComparison.Ordinal); + Assert.Contains("OnGooseAdapterConfirmed", File.ReadAllText(FindRepoFile("MainWindow.GooseTimeline.cs")), StringComparison.Ordinal); Assert.Contains("_pendingIedGooseAutoStart = null;", subscriber, StringComparison.Ordinal); Assert.Contains("!IsGooseCapturing && !GooseActionBusy", subscriber, StringComparison.Ordinal); Assert.Contains("Click=\"StartGooseSubscriber_Click\"", mainView, StringComparison.Ordinal); From 581baf241f42ad07334839755858d1d657e04121 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 06:12:42 +0700 Subject: [PATCH 5/6] fix(goose): import typed MMS value formatter for timeline payload comparison --- MainWindow.GooseTimeline.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index d302faa23..0ed13c6e4 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; +using AR.Iec61850.Mms; using ArIED61850Tester.Models; using ArIED61850Tester.Services; using ArIED61850Tester.Views; From 67266f2017339159e0af4812e4c40339fadd5c70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 06:16:24 +0700 Subject: [PATCH 6/6] test(goose): assert actual three-second expiry API; compact two-pane and command grid spacing --- MainWindow.xaml | 2 +- Views/GooseSubscriberLiteView.xaml | 4 ++-- tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index 678c69e82..f3acaadf5 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -738,7 +738,7 @@ - + diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index da71ca7f8..f3f5baa2a 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -155,9 +155,9 @@ - + - + diff --git a/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs index 64f6e4c34..fb4410aa7 100644 --- a/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs +++ b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs @@ -41,7 +41,7 @@ public void CurrentGooseUi_HasThreeSecondHighlightAndOperatorRetransmissionToggl var leaves = Read("Models/GooseSubscriberModels.cs"); Assert.Contains("ShowGooseRetransmissions", view); Assert.Contains("GooseTimelineEventPolicy.Evaluate", timeline); - Assert.Contains("TimeSpan.FromSeconds(3)", leaves); + Assert.Contains("AddSeconds(3)", leaves); Assert.DoesNotContain("IsMeaningfulGooseTimelineEvent", timeline); }