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
11 changes: 5 additions & 6 deletions MainWindow.GooseSubscriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public partial class MainWindow
private string _gooseStatusText = "Select a network adapter, then start the read-only GOOSE subscriber.";
private string _gooseBindingText = "No GOOSE model evaluated yet.";
private string _gooseCaptureFilter = GooseSubscriberRuntime.DefaultCaptureFilter;
private string _gooseCaptureContextHint = "If no frames arrive, verify publisher, adapter and VLAN.";
private long _gooseCapturedFrames;
private long _gooseFrames;
private long _gooseOtherFrames;
Expand Down Expand Up @@ -189,7 +190,7 @@ await _gooseSubscriberRuntime.StartAsync(
GooseCaptureFilter,
_applicationCancellation.Token);
IsGooseCapturing = true;
GooseStatusText = $"Listening on {SelectedGooseAdapter.DisplayText}. Waiting for GOOSE frames…";
GooseStatusText = $"Waiting for actual GOOSE frames on {SelectedGooseAdapter.DisplayText}. {_gooseCaptureContextHint}";
SetStatus("GOOSE Subscriber started in read-only capture mode.");
AddLog("INFO", "GOOSE", $"Subscriber started on adapter {SelectedGooseAdapter.Index}: {SelectedGooseAdapter.Description}. Binding: {_gooseBindingCatalog.Summary}");
}
Expand Down Expand Up @@ -275,12 +276,10 @@ 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.
// Preserve only an explicitly selected capture-device identity.
// Never silently choose the sole unrelated NIC (especially on same-host simulators).
SelectedGooseAdapter = adapters.FirstOrDefault(adapter =>
adapter.Name.Equals(previousName, StringComparison.OrdinalIgnoreCase))
?? (usableAdapters.Length == 1 ? usableAdapters[0] : null);
previousName is not null && adapter.Name.Equals(previousName, StringComparison.OrdinalIgnoreCase));
GooseStatusText = adapters.Count == 0
? "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.";
Expand Down
82 changes: 33 additions & 49 deletions MainWindow.IedGooseQuickStart.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using ArIED61850Tester.Services;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
Expand Down Expand Up @@ -124,23 +125,27 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device)
if (GooseAdapters.Count == 0)
RefreshGooseAdapters();

// 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.
// Exact OS/Npcap interface identity selects the candidate, not an
// adapter's display name or the presence of a single remaining NIC.
// The selected NIC is an observation point, NOT proof of GOOSE emission.
var proposed = ResolveGooseAdapterForIed(device, out var route);
if (proposed is not null && !LooksLikeLoopback(proposed))
_gooseCaptureContextHint = route.Contains("Same-PC", StringComparison.Ordinal)
? "Same-PC simulator: verify it actually publishes Ethernet GOOSE on this adapter."
: "If no frames arrive, check the publisher, station NIC and VLAN.";
AddLog("INFO", "GOOSE", $"Adapter selection: {route}");
if (proposed is not null)
{
SelectedGooseAdapter = proposed;
GooseStatusText = $"Listening on suggested adapter {proposed.DisplayText}. Change adapter if GOOSE uses a separate port/VLAN.";
SetStatus($"GOOSE: starting on {proposed.DisplayText}.");
GooseStatusText = $"Matched capture NIC {proposed.DisplayText}. GOOSE frame reception not yet verified.";
SetStatus($"GOOSE: observing 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 adapters."
: $"Choose the GOOSE station adapter for {device.Name}; capture begins on selection. {route}";
? "No Npcap capture adapter found. Check installation and refresh."
: $"Choose GOOSE adapter for {device.Name}; capture starts on confirmation. {route}";
SetStatus($"GOOSE: select network adapter for {device.Name}.");
}));
}
Expand All @@ -157,46 +162,29 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device)
return null;
}

var localAddress = ResolveLocalIpv4ForTarget(target);
if (localAddress is not null)
try
{
var networkInterface = NetworkInterface.GetAllNetworkInterfaces()
.FirstOrDefault(adapter => adapter.GetIPProperties().UnicastAddresses.Any(unicast =>
unicast.Address.AddressFamily == AddressFamily.InterNetwork &&
unicast.Address.Equals(localAddress)));

if (networkInterface is not null)
{
var matches = GooseAdapters
.Where(adapter => CaptureAdapterMatchesNetworkInterface(adapter, networkInterface))
.ToList();
if (matches.Count == 1)
{
routeDetail =
$"Windows route {localAddress} → {target} via {networkInterface.Name} ({networkInterface.Description})";
return matches[0];
}

routeDetail = matches.Count == 0
? $"Windows selected {networkInterface.Name} ({localAddress}), but no Npcap adapter matched its ID/MAC."
: $"Windows selected {networkInterface.Name} ({localAddress}), but {matches.Count} Npcap adapters matched.";
}
else
{
routeDetail = $"Windows selected local address {localAddress}, but its network interface was not found.";
}
var route = ResolveLocalIpv4ForTarget(target);
var interfaces = NetworkInterface.GetAllNetworkInterfaces()
.Select(nic => new GooseWindowsInterfaceSnapshot(
nic.Id,
nic.Name,
nic.Description,
nic.GetPhysicalAddress().ToString(),
nic.GetIPProperties().UnicastAddresses
.Where(unicast => unicast.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(unicast => unicast.Address)
.ToArray()))
.ToArray();
var selection = GooseAdapterSelectionPolicy.Resolve(target, route, interfaces, GooseAdapters.ToArray());
routeDetail = selection.Evidence;
return selection.Adapter;
}

var usable = GooseAdapters
.Where(adapter => !LooksLikeLoopback(adapter))
.ToList();
if (usable.Count == 1)
catch (Exception ex) when (ex is NetworkInformationException or SocketException or InvalidOperationException)
{
routeDetail += $" Falling back to the only non-loopback capture adapter: {usable[0].DisplayText}.";
return usable[0];
routeDetail = $"Network adapter identity unavailable: {ex.Message}. Select the capture adapter manually.";
return null;
}

return null;
}

private static IPAddress? ResolveLocalIpv4ForTarget(IPAddress target)
Expand Down Expand Up @@ -236,9 +224,5 @@ private static string NormalizeGooseAdapterMac(string? value)
=> Regex.Replace(value ?? string.Empty, "[^0-9A-Fa-f]", string.Empty).ToUpperInvariant();

private static bool LooksLikeLoopback(GooseAdapterOption adapter)
{
var text = $"{adapter.Name} {adapter.Description} {adapter.FriendlyName}";
return text.Contains("loopback", StringComparison.OrdinalIgnoreCase) ||
text.Contains("npcap loopback", StringComparison.OrdinalIgnoreCase);
}
=> GooseAdapterSelectionPolicy.IsNpcapPseudoLoopback(adapter);
}
133 changes: 133 additions & 0 deletions Services/GooseAdapterSelectionPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System.Net;
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

/// <summary>A snapshot of Windows interface identity; deliberately independent of live OS APIs for replay tests.</summary>
public sealed record GooseWindowsInterfaceSnapshot(
string Id,
string Name,
string Description,
string MacAddress,
IReadOnlyList<IPAddress> Ipv4Addresses);

public sealed record GooseAdapterRouteDecision(
GooseAdapterOption? Adapter,
bool IsSameHost,
string Evidence)
{
public bool HasAuthoritativeMatch => Adapter is not null;
}

/// <summary>
/// Pure identity-based GOOSE capture candidate selection. A successful MMS/IP route
/// is NOT evidence that an Ethernet GOOSE publisher exists on the capture interface.
/// The calling UI may start read-only observation only on an exact, unique match.
/// </summary>
public static class GooseAdapterSelectionPolicy
{
public static GooseAdapterRouteDecision Resolve(
IPAddress target,
IPAddress? routedLocalAddress,
IReadOnlyList<GooseWindowsInterfaceSnapshot> windowsInterfaces,
IReadOnlyList<GooseAdapterOption> captureAdapters)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(windowsInterfaces);
ArgumentNullException.ThrowIfNull(captureAdapters);

var ownsTarget = windowsInterfaces.Where(nic => HasAddress(nic, target)).ToArray();
var sameHost = IPAddress.IsLoopback(target) || ownsTarget.Length != 0;
if (sameHost)
{
if (ownsTarget.Length != 1)
return new(null, true,
"Same-PC MMS endpoint: no unique Windows adapter owns the target IP. Select a capture adapter manually; IP loopback does not prove Ethernet GOOSE.");
return Match(ownsTarget[0], captureAdapters, true);
}

if (routedLocalAddress is null)
return new(null, false, "MMS route unavailable. Select the actual GOOSE station adapter; a sole NIC is not sufficient evidence.");

var routedInterfaces = windowsInterfaces.Where(nic => HasAddress(nic, routedLocalAddress)).ToArray();
if (routedInterfaces.Length != 1)
return new(null, false,
"Windows MMS route did not resolve to one interface. Choose the GOOSE capture adapter manually.");

return Match(routedInterfaces[0], captureAdapters, false);
}

/// <summary>The dedicated Npcap software pseudo-device, NOT a Windows KM-TEST virtual Ethernet NIC.</summary>
public static bool IsNpcapPseudoLoopback(GooseAdapterOption adapter)
{
var name = adapter.Name.Trim();
return name.Equals(@"\Device\NPF_Loopback", StringComparison.OrdinalIgnoreCase) ||
name.Equals("NPF_Loopback", StringComparison.OrdinalIgnoreCase);
}

private static GooseAdapterRouteDecision Match(
GooseWindowsInterfaceSnapshot nic,
IReadOnlyList<GooseAdapterOption> adapters,
bool sameHost)
{
var context = sameHost ? "Same-PC MMS simulator" : "Windows MMS route";
var candidates = adapters.Where(adapter => !IsNpcapPseudoLoopback(adapter)).ToArray();
var winGuid = ParseGuid(nic.Id);
if (winGuid is not null)
{
var guidMatches = candidates.Where(adapter => ParseGuid(adapter.Name) == winGuid).ToArray();
if (guidMatches.Length == 1)
return new(guidMatches[0], sameHost,
$"{context}: exact Npcap/Windows interface GUID for {nic.Name}. GOOSE frames still unverified.");
if (guidMatches.Length > 1)
return new(null, sameHost,
$"{context}: multiple Npcap capture devices share the interface GUID. Choose manually.");
}

// MAC fallback is safe only when BOTH MACs are valid, the match is
// unique, and a contradictory explicit interface GUID is absent.
var mac = NormalizeValidMac(nic.MacAddress);
if (mac is not null)
{
var macMatches = candidates.Where(adapter =>
string.Equals(NormalizeValidMac(adapter.MacAddress), mac, StringComparison.Ordinal) &&
(winGuid is null || ParseGuid(adapter.Name) is null)).ToArray();
if (macMatches.Length == 1)
return new(macMatches[0], sameHost,
$"{context}: one non-conflicting Npcap adapter matched the Windows interface MAC for {nic.Name}. GOOSE frames still unverified.");
if (macMatches.Length > 1)
return new(null, sameHost,
$"{context}: several capture adapters share the MAC. Choose the GOOSE adapter manually.");
}

return new(null, sameHost,
$"{context}: no unique GUID/MAC-matched Npcap adapter for {nic.Name}. " +
(sameHost
? "KM-TEST is eligible when Npcap exposes its real Ethernet interface, but the Npcap IP-loopback pseudo-device is not equivalent."
: "The IP route is only a hint; choose the actual station Ethernet adapter."));
}

private static bool HasAddress(GooseWindowsInterfaceSnapshot nic, IPAddress address)
=> nic.Ipv4Addresses.Any(candidate => candidate.Equals(address));

private static Guid? ParseGuid(string? source)
{
if (string.IsNullOrWhiteSpace(source))
return null;
var first = source.IndexOf('{');
if (first < 0)
return Guid.TryParse(source, out var standalone) ? standalone : null;
var last = source.IndexOf('}', first + 1);
return last > first && Guid.TryParse(source.AsSpan(first, last - first + 1), out var guid)
? guid : null;
}

private static string? NormalizeValidMac(string? input)
{
if (string.IsNullOrWhiteSpace(input))
return null;
var mac = new string(input.Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray());
return mac.Length is 12 or 16 && mac.Any(ch => ch != '0') && mac.Any(ch => ch != 'F')
? mac : null;
}
}
2 changes: 1 addition & 1 deletion Views/GooseSubscriberLiteView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
</Border>
<TextBlock Grid.Column="2" Text="{Binding GooseStatusText}" FontSize="10.5"
Foreground="{StaticResource Muted}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip="{Binding GooseBindingText}"/>
TextTrimming="CharacterEllipsis" ToolTip="{Binding GooseStatusText}"/>
<TextBlock Grid.Column="3" Text="{Binding GooseCounterText}" FontSize="10.5"
FontWeight="SemiBold" Foreground="#45648E" VerticalAlignment="Center"/>
</Grid>
Expand Down
7 changes: 7 additions & 0 deletions docs/GOOSE_SUBSCRIBER.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,10 @@ Validate the feature with a known publisher and SCL before relying on names in p
- Vendor-specific structured DataSet members are rendered compactly as one `allData` item. The grid does not invent nested field names beyond metadata provided by ARIEC61850.
- Npcap capture visibility depends on Windows driver installation, adapter binding, VLAN offload behavior, and switch port mirroring/network topology.
- This workspace provides engineering evidence, not an IEC 61850 conformance certificate or cybersecurity assurance.


## Same-PC C264 adapter selection (P8, draft field candidate)

The IED-card GOOSE entry now resolves the capture NIC from authoritative Windows/Npcap interface GUID, then a unique non-conflicting valid MAC. For an MMS endpoint hosted on this computer, the **interface owning the target IPv4** takes precedence over the Windows socket route. A KM-TEST virtual Ethernet adapter is not rejected just because its friendly name says "loopback"; only the dedicated Npcap `NPF_Loopback` pseudo-interface is excluded from automatic Ethernet capture. If identity is missing or ambiguous, the operator selects the adapter manually; no random one-NIC fallback occurs. The manual picker still supports explicit Npcap loopback investigation.

Clicking the IED-card GOOSE action may begin *read-only observation* when an exact unique NIC match exists; it is **not** evidence that a GOOSE publisher emitted a frame. The top bar explains that actual Ethernet GOOSE reception remains pending and suggests checking whether the simulator publishes and whether that adapter receives multicast/VLAN traffic. These conditions must be verified on the operator's PC before claiming capture parity. No new native transport, background adapter scan, per-packet UI timer, or control/write traffic is introduced.
Loading
Loading