Skip to content
Merged
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
2 changes: 1 addition & 1 deletion IoListTestingWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
xmlns:io="clr-namespace:ArIED61850Tester.Models.IoTesting"
xmlns:local="clr-namespace:ArIED61850Tester"
Title="ARSAS - IO List Testing"
Height="820" Width="1440" MinHeight="640" MinWidth="1100"
Height="920" Width="1680" MinHeight="640" MinWidth="1100"
WindowStartupLocation="CenterOwner" WindowState="Normal"
Background="{StaticResource AppBackgroundGradient}"
FontFamily="{StaticResource AppFontFamily}"
Expand Down
70 changes: 57 additions & 13 deletions MainWindow.GooseSubscriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public partial class MainWindow
private long _gooseOtherFrames;
private bool _gooseWorkspaceActivationScheduled;
private bool _gooseBindingRefreshScheduled;
// Native adapter discovery must never execute during a tab navigation.
private Task? _gooseNavigationAdapterScanTask;

public BulkObservableCollection<GooseAdapterOption> GooseAdapters { get; } = new();
public BulkObservableCollection<GooseStreamRow> GooseStreams { get; } = new();
Expand Down Expand Up @@ -133,23 +135,65 @@ private void ActivateGooseSubscriberWorkspace()
return;

_gooseWorkspaceActivationScheduled = true;
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
// GOOSE tab switching is presentation-only. Model compilation already
// follows workspace changes, and Start/Refresh Models can explicitly
// request a fresh catalog. Neither SCL scans nor synchronous Npcap
// enumeration belong on the navigation/rendering dispatcher path.
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
{
_gooseWorkspaceActivationScheduled = false;
try
{
RefreshGooseBindingPreview();
if (GooseAdapters.Count == 0)
RefreshGooseAdapters();
}
catch (Exception ex)
if (MainTabs.SelectedIndex != GooseSubscriberTabIndex || IsGooseCapturing)
return;
if (GooseAdapters.Count == 0)
_ = EnsureGooseAdaptersForNavigationAsync();
}));
}

// Deduplicated worker shared by ordinary tab navigation and IED-card entry.
private Task EnsureGooseAdaptersForNavigationAsync()
{
if (_gooseNavigationAdapterScanTask is { IsCompleted: false } running)
return running;
if (IsGooseCapturing || Dispatcher.HasShutdownStarted || GooseAdapters.Count != 0)
return Task.CompletedTask;
_gooseNavigationAdapterScanTask = LoadGooseAdaptersForNavigationAsync();
return _gooseNavigationAdapterScanTask;
}

private async Task LoadGooseAdaptersForNavigationAsync()
{
try
{
// Native enumerations can stall several seconds on multi-NIC PCs.
// Only the discovery occurs on a worker; every bound collection
// mutation stays on WPF's synchronization context.
var adapters = await Task.Run(
() => _gooseSubscriberRuntime.ListAdapters());
if (Dispatcher.HasShutdownStarted || IsGooseCapturing ||
GooseAdapters.Count != 0)
return;
var previousName = SelectedGooseAdapter?.Name;
GooseAdapters.ReplaceAll(adapters);
SelectedGooseAdapter = adapters.FirstOrDefault(adapter =>
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.";
}
catch (Exception ex)
{
// Failing optional capture discovery must not freeze or blank the tab.
if (!Dispatcher.HasShutdownStarted && !IsGooseCapturing)
{
// The selected tab has already rendered by this point. Keep the workspace
// usable in unbound/wire-order mode instead of letting a model or native
// dependency exception abort TabControl content creation.
ApplyGooseWorkspaceFallback("GOOSE workspace initialization failed", ex);
GooseStatusText = $"Npcap adapter discovery failed: {DescribeGooseFailure(ex)}";
AddLog("WARN", "GOOSE", GooseStatusText);
}
}));
}
finally
{
_gooseNavigationAdapterScanTask = null;
}
}

private void RefreshGooseAdapters_Click(object sender, RoutedEventArgs e)
Expand Down
44 changes: 36 additions & 8 deletions MainWindow.IedGooseQuickStart.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device)
MainTabs.SelectedIndex = GooseSubscriberTabIndex;
UpdateNavigationVisuals(GooseSubscriberTabIndex, animate: true);
ActivateGooseSubscriberWorkspace();
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(async () =>
{
if (Dispatcher.HasShutdownStarted || MainTabs.SelectedIndex != GooseSubscriberTabIndex)
return;
Expand All @@ -133,13 +133,39 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device)
: "GOOSE capture is busy. Wait for the current operation.";
return;
}
// Share the same asynchronous Npcap discovery as ordinary tab
// navigation. A background worker also resolves OS NIC routing:
// neither scan can block the first GOOSE frame/render.
if (GooseAdapters.Count == 0)
RefreshGooseAdapters();
await EnsureGooseAdaptersForNavigationAsync();
if (Dispatcher.HasShutdownStarted || MainTabs.SelectedIndex != GooseSubscriberTabIndex ||
!ReferenceEquals(SelectedDevice, device) || IsGooseCapturing || GooseActionBusy)
return;

// 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);
// Capture a frozen list on the WPF thread before examining network
// interfaces off-thread. The candidate is observation only, not
// proof that an IED is publishing Ethernet GOOSE.
var candidates = GooseAdapters.ToArray();
GooseAdapterOption? proposed;
string route;
try
{
var resolved = await Task.Run(() =>
{
var adapter = ResolveGooseAdapterForIed(device, out var evidence, candidates);
return (Adapter: adapter, Evidence: evidence);
});
proposed = resolved.Adapter;
route = resolved.Evidence;
}
catch (Exception ex)
{
proposed = null;
route = $"Network adapter routing could not be resolved: {ex.Message}. Select the capture adapter manually.";
}
if (Dispatcher.HasShutdownStarted || MainTabs.SelectedIndex != GooseSubscriberTabIndex ||
!ReferenceEquals(SelectedDevice, device) || IsGooseCapturing || GooseActionBusy)
return;
_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.";
Expand All @@ -163,7 +189,8 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device)

private GooseAdapterOption? ResolveGooseAdapterForIed(
Iec61850MonitorDevice device,
out string routeDetail)
out string routeDetail,
IReadOnlyList<GooseAdapterOption>? capturedAdapters = null)
{
routeDetail = "No route was resolved.";
if (!IPAddress.TryParse(device.IpAddress, out var target) ||
Expand All @@ -187,7 +214,8 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device)
.Select(unicast => unicast.Address)
.ToArray()))
.ToArray();
var selection = GooseAdapterSelectionPolicy.Resolve(target, route, interfaces, GooseAdapters.ToArray());
var selection = GooseAdapterSelectionPolicy.Resolve(
target, route, interfaces, capturedAdapters ?? GooseAdapters.ToArray());
routeDetail = selection.Evidence;
return selection.Adapter;
}
Expand Down
10 changes: 5 additions & 5 deletions MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ArIED61850Tester.Views"
Title="ARSAS - Smart IEC 61850 Communication Tester"
Height="840" Width="1440" MinHeight="640" MinWidth="1100"
Height="960" Width="1740" MinHeight="640" MinWidth="1100"
WindowStartupLocation="CenterScreen" WindowState="Normal" Background="{StaticResource AppBackgroundGradient}"
FontFamily="{StaticResource AppFontFamily}"
UseLayoutRounding="True" SnapsToDevicePixels="True"
Expand Down Expand Up @@ -2022,8 +2022,8 @@
Width="16" Height="16" Stretch="Uniform"
Fill="#277455" VerticalAlignment="Center"
HorizontalAlignment="Center"
Visibility="{Binding RcbEnabledVisibility}"
ToolTip="{Binding RcbEnabledTooltip}"/>
Visibility="{Binding RcbActivityIconVisibility}"
ToolTip="{Binding RcbActivityIconTooltip}"/>
</Grid>
</Border>
</DataTemplate>
Expand Down Expand Up @@ -2230,8 +2230,8 @@
Width="15" Height="15" Stretch="Uniform"
Fill="#277455" VerticalAlignment="Center"
HorizontalAlignment="Center"
Visibility="{Binding RcbEnabledVisibility}"
ToolTip="{Binding RcbEnabledTooltip}"/>
Visibility="{Binding RcbActivityIconVisibility}"
ToolTip="{Binding RcbActivityIconTooltip}"/>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
Expand Down
30 changes: 30 additions & 0 deletions Models/IedBrowserPresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@
? Visibility.Visible : Visibility.Collapsed;
public string RcbEnabledTooltip => _rcbEnabledTooltip;

// RptEna (a verified MMS Read) and current-session successful RCB
// activation are separate evidences. Show the requested activity icon
// for either, without falsely claiming that a local activation is a
// fresh remote RptEna read or that another client's RCB belongs to us.
public Visibility RcbActivityIconVisibility =>
RcbEnabledVisibility == Visibility.Visible ||
Payload is RcbExportRow
{
Availability: AR.Iec61850.Mms.MmsRcbOperationalAvailability.UsedByCaller
}
? Visibility.Visible : Visibility.Collapsed;
public string RcbActivityIconTooltip => RcbEnabledVisibility == Visibility.Visible
? RcbEnabledTooltip
: Payload is RcbExportRow
{
Availability: AR.Iec61850.Mms.MmsRcbOperationalAvailability.UsedByCaller
}
? RcbUsageTooltip
: string.Empty;

public bool SetRcbEnabled(bool verified, string tooltip)
{
if (Payload is not RcbExportRow) return false;
Expand All @@ -67,6 +87,8 @@
_rcbEnabledTooltip = tooltip;
Raise(nameof(RcbEnabledVisibility));
Raise(nameof(RcbEnabledTooltip));
Raise(nameof(RcbActivityIconVisibility));
Raise(nameof(RcbActivityIconTooltip));
return true;
}
public string RcbUsageTooltip => Payload is RcbExportRow report
Expand All @@ -88,6 +110,8 @@
{
if (Payload is not RcbExportRow) return;
Raise(nameof(RcbUsageTooltip));
Raise(nameof(RcbActivityIconVisibility));
Raise(nameof(RcbActivityIconTooltip));
}

private bool _isExpanded;
Expand Down Expand Up @@ -288,6 +312,8 @@
public Visibility RcbEnabledVisibility => NavigateTo?.RcbEnabledVisibility ?? Visibility.Collapsed;
public string RcbEnabledTooltip => NavigateTo?.RcbEnabledTooltip ?? "";
public string RcbUsageTooltip => NavigateTo?.RcbUsageTooltip ?? "";
public Visibility RcbActivityIconVisibility => NavigateTo?.RcbActivityIconVisibility ?? Visibility.Collapsed;
public string RcbActivityIconTooltip => NavigateTo?.RcbActivityIconTooltip ?? "";

/// <summary>Mirror the same RCB node's active state in the right list.</summary>
public void RefreshRcbUsage()
Expand All @@ -299,6 +325,10 @@
new PropertyChangedEventArgs(nameof(RcbEnabledTooltip)));
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(nameof(RcbUsageTooltip)));
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(nameof(RcbActivityIconVisibility)));
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(nameof(RcbActivityIconTooltip)));
if (Kind == "Object") Value = rcb.StatusText;
}

Expand Down Expand Up @@ -372,7 +402,7 @@
string? iedName = null,
LiveIedModelDiscoveryDocument? sclModel = null)
{
const int childDepth = 1;

Check warning on line 405 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 405 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 405 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used
// Keep domain/object keys untouched; only presentation labels are short.
var labelIed = !string.IsNullOrWhiteSpace(model?.IedName)
? model.IedName : iedName;
Expand Down
17 changes: 12 additions & 5 deletions WorkstationWindowStartupBounds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,19 @@ internal static void Apply(Window window)
return;

var work = SystemParameters.WorkArea; // WPF device-independent units.
const double inset = 24d;
var maxWidth = Math.Max(window.MinWidth, work.Width - 2d * inset);
var maxHeight = Math.Max(window.MinHeight, work.Height - 2d * inset);
// Use almost all available desktop space on first launch, without
// maximizing. The old 1440×840 cap truncated timestamps and the
// Command Dock on 1600–1920px engineering workstations.
const double inset = 8d;
var maxWidth = Math.Max(0d, work.Width - 2d * inset);
var maxHeight = Math.Max(0d, work.Height - 2d * inset);
if (maxWidth <= 0d || maxHeight <= 0d)
return;

window.Width = Math.Clamp(window.Width, window.MinWidth, maxWidth);
window.Height = Math.Clamp(window.Height, window.MinHeight, maxHeight);
// MinWidth/MinHeight are allowed only when the actual monitor can
// support them. Preserve native dragging and future user resizing.
window.Width = Math.Min(maxWidth, Math.Max(window.Width, Math.Min(window.MinWidth, maxWidth)));
window.Height = Math.Min(maxHeight, Math.Max(window.Height, Math.Min(window.MinHeight, maxHeight)));
window.Left = work.Left + Math.Max(0d, (work.Width - window.Width) / 2d);
window.Top = work.Top + Math.Max(0d, (work.Height - window.Height) / 2d);
}
Expand Down
9 changes: 8 additions & 1 deletion tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,14 @@ public void XamlUsesOriginalSingleVectorInInlineFixedRcbNameSlot()
Assert.Contains("<ColumnDefinition Width=\"200\"/>", rcb);
Assert.Contains("<ColumnDefinition Width=\"24\"/>", rcb);
Assert.Contains("Grid.Column=\"2\" Data=\"{StaticResource BrowserRcbEnabledIconGeometry}\"", rcb);
Assert.Contains("Visibility=\"{Binding RcbEnabledVisibility}\"", rcb);
Assert.Contains("Visibility=\"{Binding RcbActivityIconVisibility}\"", rcb);
Assert.Contains("ToolTip=\"{Binding RcbActivityIconTooltip}\"", rcb);
// The same vector now distinguishes verified MMS RptEna and the
// current association's successful activation. It does NOT equate
// another client's InUse or an observed report with verified RptEna.
var model = File.ReadAllText(Path.Combine(root!.FullName, "Models", "IedBrowserPresentation.cs"));
Assert.Contains("RcbEnabledVisibility == Visibility.Visible", model);
Assert.Contains("MmsRcbOperationalAvailability.UsedByCaller", model);
Assert.DoesNotContain("RcbUsageGlyph", xaml);
Assert.Contains("<Geometry x:Key=\"BrowserRcbEnabledIconGeometry\">M380-320h200", xaml);
}
Expand Down
Loading
Loading