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
103 changes: 77 additions & 26 deletions MainWindow.GooseSubscriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ namespace ArIED61850Tester;
public partial class MainWindow
{
private readonly GooseSubscriberRuntime _gooseSubscriberRuntime = new();
private const int MaxGooseCachedStreams = 256;
private readonly object _gooseCacheGate = new();
private readonly ConcurrentDictionary<string, GooseSubscriberFrameSnapshot> _pendingGooseFrames = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, GooseSubscriberFrameSnapshot> _latestGooseFrames = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, GooseStreamRow> _gooseStreamIndex = new(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -217,8 +219,11 @@ private void ClearGooseSubscriber_Click(object sender, RoutedEventArgs e)

private void ResetGooseView(bool resetCounters)
{
_pendingGooseFrames.Clear();
_latestGooseFrames.Clear();
lock (_gooseCacheGate)
{
_pendingGooseFrames.Clear();
_latestGooseFrames.Clear();
}
_gooseStreamIndex.Clear();
GooseStreams.Clear();
SelectedGooseStream = null;
Expand Down Expand Up @@ -364,8 +369,33 @@ private static string DescribeGooseFailure(Exception exception)

private void GooseSubscriberRuntime_FrameReceived(GooseSubscriberFrameSnapshot snapshot)
{
_latestGooseFrames[snapshot.StreamKey] = snapshot;
_pendingGooseFrames[snapshot.StreamKey] = snapshot;
// Canonical cache: one latest typed frame per stream. Retransmissions
// replace previous state rather than allocating a new GUI object.
// Eviction occurs only for a NEW stream beyond budget; normal capture
// therefore takes O(1) dictionary operations without sorting.
lock (_gooseCacheGate)
{
if (!_latestGooseFrames.ContainsKey(snapshot.StreamKey) &&
_latestGooseFrames.Count >= MaxGooseCachedStreams)
{
KeyValuePair<string, GooseSubscriberFrameSnapshot>? oldest = null;
foreach (var entry in _latestGooseFrames)
{
if (!oldest.HasValue ||
entry.Value.CaptureTimestamp < oldest.Value.Value.CaptureTimestamp)
oldest = entry;
}
if (oldest.HasValue)
{
_latestGooseFrames.TryRemove(oldest.Value.Key, out _);
_pendingGooseFrames.TryRemove(oldest.Value.Key, out _);
lock (_gooseTimelineGate)
_lastGooseTimelineSignature.Remove(oldest.Value.Key);
}
}
_latestGooseFrames[snapshot.StreamKey] = snapshot;
_pendingGooseFrames[snapshot.StreamKey] = snapshot;
}
}

private void GooseSubscriberRuntime_StatusChanged(GooseSubscriberStatusSnapshot status)
Expand Down Expand Up @@ -698,8 +728,10 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot(
var bindingSource = decoded?.IsMappedToScl == true
? "SCL"
: binding?.Source ?? "Unbound";
var resolvedCdc = decoded?.IsMappedToScl == true ? decoded.Cdc : definition?.Cdc ?? string.Empty;
var resolvedType = decoded?.IsMappedToScl == true ? decoded.BType : definition?.BType ?? string.Empty;
var value = index < rawValueCount
? decoded?.DisplayValue ?? MmsDataValueRenderer.ToCompactString(frame.Pdu.Values[index], signalReference)
? GooseTypedValueInterpreter.Render(frame.Pdu.Values[index], resolvedCdc, resolvedType, signalReference)
: "<missing in frame>";

leaves.Add(new GooseLeafValueSnapshot(
Expand All @@ -708,8 +740,8 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot(
definition?.SignalName ?? BuildSignalName(signalReference, index),
signalReference,
decoded?.IsMappedToScl == true ? decoded.Fc : definition?.FunctionalConstraint ?? string.Empty,
decoded?.IsMappedToScl == true ? decoded.Cdc : definition?.Cdc ?? string.Empty,
decoded?.IsMappedToScl == true ? decoded.BType : definition?.BType ?? string.Empty,
resolvedCdc,
resolvedType,
value,
decoded?.PreviousDisplayValue ?? string.Empty,
decoded?.IsChanged ?? false,
Expand Down Expand Up @@ -770,7 +802,7 @@ private static string FirstNonEmpty(params string?[] values)
private static string BuildSignalName(string reference, int index)
{
if (string.IsNullOrWhiteSpace(reference))
return $"Leaf {index + 1}";
return $"Unmapped value #{index + 1}";

var clean = Regex.Replace(reference, @"\[[^\]]+\]$", string.Empty);
var slash = clean.LastIndexOf('/');
Expand Down Expand Up @@ -831,32 +863,51 @@ public GooseBindingCatalog(SclDocument? sclDocument, IReadOnlyList<GooseStreamBi

public GooseStreamBindingDefinition? Resolve(GooseFrame frame)
{
GooseStreamBindingDefinition? best = null;
// Never bind by GoID alone (not globally unique), by suffix, or by
// similar-looking DataSet names. Require an exact wire identity,
// equal revision where available, and equal flattened value count.
// Ambiguous top-scoring matches remain unbound rather than
// assigning someone else's electrical signal names.
GooseStreamBindingDefinition? winner = null;
var bestScore = 0;
foreach (var candidate in Bindings)
var ambiguous = false;
var frameDataSet = NormalizeGooseReference(frame.Pdu.DataSetReference);
var frameGoCb = NormalizeGooseReference(frame.Pdu.GoCbRef);
foreach (var binding in Bindings)
{
var score = 0;
if (candidate.AppId.HasValue && candidate.AppId.Value == frame.AppId)
score += 50;
if (ReferencesMatch(NormalizeGooseReference(candidate.GoCbRef), NormalizeGooseReference(frame.Pdu.GoCbRef)))
score += 120;
if (ReferencesMatch(NormalizeGooseReference(candidate.DataSetReference), NormalizeGooseReference(frame.Pdu.DataSetReference)))
score += 90;
if (!string.IsNullOrWhiteSpace(candidate.GoId) && candidate.GoId.Equals(frame.Pdu.GoId, StringComparison.OrdinalIgnoreCase))
score += 40;
if (candidate.ConfigurationRevision.HasValue && candidate.ConfigurationRevision.Value == frame.Pdu.ConfigurationRevision)
score += 10;
if (candidate.Source.Equals("SCL", StringComparison.OrdinalIgnoreCase))
score += 5;

if (binding.Leaves.Count != frame.Pdu.Values.Count)
continue;
if (binding.AppId.HasValue && binding.AppId.Value != frame.AppId)
continue;
if (binding.ConfigurationRevision.HasValue &&
binding.ConfigurationRevision.Value != frame.Pdu.ConfigurationRevision)
continue;

var goCb = NormalizeGooseReference(binding.GoCbRef);
var dataSet = NormalizeGooseReference(binding.DataSetReference);
if (goCb.Length > 0 && !string.Equals(goCb, frameGoCb, StringComparison.OrdinalIgnoreCase))
continue;
if (dataSet.Length > 0 && !string.Equals(dataSet, frameDataSet, StringComparison.OrdinalIgnoreCase))
continue;

var goCbExact = goCb.Length > 0;
var dataSetExact = dataSet.Length > 0;
if (!goCbExact && !dataSetExact) continue;
var score = (goCbExact ? 120 : 0) + (dataSetExact ? 90 : 0) +
(binding.AppId.HasValue ? 50 : 0) +
(binding.ConfigurationRevision.HasValue ? 10 : 0) +
(binding.Source.Equals("SCL", StringComparison.OrdinalIgnoreCase) ? 5 : 0);
if (score > bestScore)
{
winner = binding;
bestScore = score;
best = candidate;
ambiguous = false;
}
else if (score == bestScore)
ambiguous = true;
}

return bestScore >= 90 ? best : null;
return ambiguous ? null : winner;
}
}
}
76 changes: 60 additions & 16 deletions MainWindow.GooseTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public bool ShowGooseRetransmissions
get => _showGooseRetransmissions;
set => Set(ref _showGooseRetransmissions, value);
}
private DateTimeOffset? _gooseTimelineStart;
private readonly Dictionary<string, DateTimeOffset> _lastGooseTimelineTimestamp = new(StringComparer.OrdinalIgnoreCase);
private GooseEventRow? _selectedGooseEvent;
private bool _goosePresentationInstalled;
Expand All @@ -40,14 +41,29 @@ public GooseEventRow? SelectedGooseEvent
if (!Set(ref _selectedGooseEvent, value))
return;

if (value is not null && _gooseStreamIndex.TryGetValue(value.StreamKey, out var stream))
SelectedGooseStream = stream;
if (value is not null)
{
// Inspector is immutable for the chosen historical frame.
// Streaming latest publisher state must never mutate this row.
if (value.Snapshot is { } snapshot)
{
var historical = new GooseStreamRow { StreamKey = value.StreamKey };
historical.Apply(snapshot);
SelectedGooseStream = historical;
}
else if (_gooseStreamIndex.TryGetValue(value.StreamKey, out var simulated))
SelectedGooseStream = simulated;
}
else
SelectedGooseStream = null;
}
}

public string GoosePublisherCountText => $"{GooseStreams.Count:N0}";
public string GooseEventCountText => $"{GooseEvents.Count:N0}";
public string GooseSelectedLeafCountText => $"{SelectedGooseStream?.Leaves.Count ?? 0:N0} values";
public string GooseSelectedLeafCountText => SelectedGooseStream is null
? "0 signals"
: $"{SelectedGooseStream.EngineeringLeaves.Count:N0} signals · {SelectedGooseStream.Leaves.Count:N0} entries";
public Visibility GooseNoEventsVisibility => GooseEvents.Count == 0 ? Visibility.Visible : Visibility.Collapsed;

[ModuleInitializer]
Expand All @@ -69,6 +85,10 @@ internal static void RegisterGoosePresentationIntegration()
typeof(MainWindow),
GooseSubscriberLiteView.AdapterConfirmedEvent,
new RoutedEventHandler(OnGooseAdapterConfirmed));
EventManager.RegisterClassHandler(
typeof(MainWindow),
GooseSubscriberLiteView.ImportPublisherModelRequestedEvent,
new RoutedEventHandler(OnImportGoosePublisherModelRequested));
EventManager.RegisterClassHandler(
typeof(MainWindow),
GooseSubscriberLiteView.StartRequestedEvent,
Expand Down Expand Up @@ -138,6 +158,16 @@ private static void OnGooseAdapterConfirmed(object sender, RoutedEventArgs args)
args.Handled = true;
}

private static void OnImportGoosePublisherModelRequested(object sender, RoutedEventArgs args)
{
if (sender is not MainWindow window)
return;
// Uses the existing, engine-validated SCL import workflow. Never infer
// publisher IP from GOOSE source MAC or synthesize missing FCDA identities.
window.OpenScl_Click(window, new RoutedEventArgs());
args.Handled = true;
}

private static void OnStartGooseRequested(object sender, RoutedEventArgs args)
{
if (sender is not MainWindow window)
Expand Down Expand Up @@ -179,6 +209,7 @@ private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot frame)
GooseTimelineDecision decision;
lock (_gooseTimelineGate)
{
_gooseTimelineStart ??= frame.CaptureTimestamp;
_lastGooseTimelineSignature.TryGetValue(frame.StreamKey, out var previous);
decision = GooseTimelineEventPolicy.Evaluate(previous, fingerprint, ShowGooseRetransmissions);
_lastGooseTimelineSignature[frame.StreamKey] = fingerprint;
Expand Down Expand Up @@ -238,15 +269,17 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured,
var valueAnomaly = decision.PayloadChanged && !decision.StateChanged;
var changed = decision.PayloadChanged || decision.StateChanged;
var eventText = decision.IsNew ? "New" :
valueAnomaly ? "Value changed" :
valueAnomaly ? "Unexpected change" :
changed ? "Changed" :
hasNewWarning || decision.NewSequenceAnomaly ? "Warning" :
changed ? "State change" : FriendlySequenceStatus(stream.SequenceStatus);
FriendlySequenceStatus(stream.SequenceStatus);
var tone = valueAnomaly || hasNewWarning || decision.NewSequenceAnomaly ? "Warning" :
changed ? "Change" : "Info";
var deltaText = "-";
if (_lastGooseTimelineTimestamp.TryGetValue(captured.StreamKey, out var prev))
deltaText = FormatGooseDelta(captured.CaptureTimestamp - prev);
_lastGooseTimelineTimestamp[captured.StreamKey] = captured.CaptureTimestamp;
_gooseTimelineStart ??= captured.CaptureTimestamp;
return new GooseEventRow
{
StreamKey = captured.StreamKey,
Expand All @@ -256,8 +289,14 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured,
EventTone = tone,
Publisher = BuildGoosePublisherName(stream),
StateSequenceText = $"{stream.StateNumberText} / {stream.SequenceNumberText}",
Summary = valueAnomaly ? "Payload changed without stNum increment — verify publisher." :
BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning)
SourceMac = stream.SourceMac,
DestinationMac = stream.DestinationMac,
DataSetName = ShortGooseReference(stream.DataSetReference),
RelativeTime = (captured.CaptureTimestamp - _gooseTimelineStart.Value).TotalSeconds.ToString("0.000000", CultureInfo.InvariantCulture),
Snapshot = stream,
Summary = valueAnomaly
? "Unexpected stNum: " + BuildGooseEventSummary(stream, false, hasNewWarning)
: BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning)
};
}

Expand All @@ -274,18 +313,21 @@ private static string BuildGoosePublisherName(GooseStreamSnapshot stream)

private static string BuildGooseEventSummary(GooseStreamSnapshot stream, bool isNew, bool hasDiagnostics)
{
if (hasDiagnostics)
return ShortenGooseText(stream.DiagnosticsSummary, 150);

// The details column must lead with actual signal deltas, not a
// persistent Test=true banner or a raw sequence diagnostic.
var changed = stream.Leaves
.Where(leaf => leaf.IsChanged)
.Take(2)
.Select(leaf =>
{
var current = ShortenGooseText(GooseEngineeringValueFormatter.Format(leaf.Value), 30);
return IsGenericGooseLeafName(leaf.SignalName)
? current
: $"{ShortenGooseText(leaf.SignalName, 22)}: {current}";
var name = IsGenericGooseLeafName(leaf.SignalName)
? $"Value {leaf.Order}"
: ShortenGooseText(leaf.SignalName, 22);
var before = GooseTypedValueInterpreter.RenderPrevious(leaf.PreviousValue, leaf.Cdc, leaf.BType);
var after = ShortenGooseText(leaf.Value, 26);
return string.IsNullOrWhiteSpace(before)
? $"{name}: {after}"
: $"{name}: {ShortenGooseText(before, 20)} → {after}";
})
.ToArray();
if (changed.Length > 0)
Expand All @@ -297,8 +339,9 @@ private static string BuildGooseEventSummary(GooseStreamSnapshot stream, bool is
}

if (isNew)
return $"Publisher detected • {stream.Leaves.Count:N0} DataSet value(s)";

return $"Publisher detected · {stream.Leaves.Count:N0} DataSet entries";
if (hasDiagnostics)
return ShortenGooseText(stream.DiagnosticsSummary, 150);
return FriendlySequenceStatus(stream.SequenceStatus);
}

Expand Down Expand Up @@ -357,6 +400,7 @@ private void ResetGooseTimelineUi()
{
}
_lastGooseTimelineTimestamp.Clear();
_gooseTimelineStart = null;
lock (_gooseTimelineGate) _lastGooseTimelineSignature.Clear();
_nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue;
GooseEvents.Clear();
Expand Down
18 changes: 14 additions & 4 deletions MainWindow.IedGooseQuickStart.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,23 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device)
if (GooseAdapters.Count == 0)
RefreshGooseAdapters();

// Never assume the unicast MMS route is the GOOSE L2 network. The
// Npcap adapter must be explicitly selected before capture begins.
// MMS route is an initial NIC recommendation, not proof of the
// station GOOSE VLAN. Only auto-start when one non-loopback Npcap
// adapter uniquely matches the actual Windows-routed interface.
var proposed = ResolveGooseAdapterForIed(device, out var route);
if (proposed is not null && !LooksLikeLoopback(proposed))
{
SelectedGooseAdapter = proposed;
GooseStatusText = $"Listening on suggested adapter {proposed.DisplayText}. Change adapter if GOOSE uses a separate port/VLAN.";
SetStatus($"GOOSE: starting on {proposed.DisplayText}.");
StartGooseSubscriber_Click(this, new RoutedEventArgs());
return;
}
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.";
? "No Ethernet capture adapter found. Check Npcap and refresh adapters."
: $"Choose the GOOSE station adapter for {device.Name}; capture begins on selection. {route}";
SetStatus($"GOOSE: select network adapter for {device.Name}.");
}));
}
Expand Down
9 changes: 8 additions & 1 deletion Models/GoosePresentationModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public string DisplaySecondary
public string StateSequenceText => $"{StateNumberText} / {SequenceNumberText}";
public string StateSequenceCompactText => $"st {StateNumberText} • sq {SequenceNumberText}";
public string ModelStateText => BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase)
? "Raw / unbound"
? "Signal names unavailable · open matching publisher SCL/CID"
: BindingSource;

private void RaisePresentationProperties()
Expand Down Expand Up @@ -72,6 +72,13 @@ public sealed class GooseEventRow : ObservableObject
public required string Publisher { get; init; }
public required string StateSequenceText { get; init; }
public required string Summary { get; init; }
// Real wire events populate all six-column fields plus an immutable frame.
// Simulated demo events may omit the frame; they keep their explicit values.
public string SourceMac { get; init; } = "—";
public string DestinationMac { get; init; } = "—";
public string DataSetName { get; init; } = "—";
public string RelativeTime { get; init; } = "—";
public GooseStreamSnapshot? Snapshot { get; init; }

public bool IsRecent { get => _isRecent; private set => Set(ref _isRecent, value); }
public string TimeText => Timestamp.ToLocalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
Expand Down
Loading
Loading