Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions FaultRecordUxBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}

Expand Down
29 changes: 26 additions & 3 deletions MainWindow.GooseSubscriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public partial class MainWindow
private readonly Dictionary<string, GooseStreamRow> _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;
Expand All @@ -44,6 +46,7 @@ public GooseAdapterOption? SelectedGooseAdapter
if (!Set(ref _selectedGooseAdapter, value)) return;
Raise(nameof(CanStartGooseSubscriber));
Raise(nameof(SelectedGooseAdapterDetail));

}
}

Expand Down Expand Up @@ -152,8 +155,24 @@ private void RefreshGooseAdapters_Click(object sender, RoutedEventArgs e)
private void RefreshGooseModels_Click(object sender, RoutedEventArgs e)
=> RefreshGooseBindingPreview();

/// <summary>
/// The IED-card intent is consumed only by an operator adapter choice.
/// Refresh or a programmatic ComboBox binding cannot auto-start capture.
/// </summary>
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;
if (SelectedGooseAdapter is null || GooseActionBusy || IsGooseCapturing)
return;

Expand Down Expand Up @@ -251,11 +270,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)
{
Expand Down
115 changes: 64 additions & 51 deletions MainWindow.GooseTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,10 +13,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<GooseSubscriberFrameSnapshot> _pendingGooseTimeline = new();
private readonly ConcurrentQueue<(GooseSubscriberFrameSnapshot Frame, GooseTimelineDecision Decision)> _pendingGooseTimeline = new();
private readonly Dictionary<string, GooseTimelineSignature> _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<string, DateTimeOffset> _lastGooseTimelineTimestamp = new(StringComparer.OrdinalIgnoreCase);
private GooseEventRow? _selectedGooseEvent;
private bool _goosePresentationInstalled;
Expand Down Expand Up @@ -56,6 +65,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,
Expand Down Expand Up @@ -117,6 +130,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)
Expand All @@ -143,26 +164,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)
Expand All @@ -171,7 +195,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)
Expand All @@ -180,8 +204,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)
Expand All @@ -206,46 +230,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)
};
}

Expand Down Expand Up @@ -345,6 +357,7 @@ private void ResetGooseTimelineUi()
{
}
_lastGooseTimelineTimestamp.Clear();
lock (_gooseTimelineGate) _lastGooseTimelineSignature.Clear();
_nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue;
GooseEvents.Clear();
SelectedGooseEvent = null;
Expand Down
Loading
Loading