diff --git a/MainWindow.GooseIedScope.cs b/MainWindow.GooseIedScope.cs index 70114bb88..c5e12d1da 100644 --- a/MainWindow.GooseIedScope.cs +++ b/MainWindow.GooseIedScope.cs @@ -8,7 +8,7 @@ namespace ArIED61850Tester; public partial class MainWindow { // 0 = selected IED; 1 = all IEDs. Never filters the capture/decoder. - private int _gooseIedScopeIndex; + private int _gooseIedScopeIndex = 1; // Unresolved publishers must be visible at first capture. private ListCollectionView? _gooseEventScopeView; private ListCollectionView? _gooseStreamScopeView; @@ -27,6 +27,20 @@ public int GooseIedScopeIndex } } + // Keep the 500-event canonical timeline unchanged; this is only the + // presentation scope. A selected IED cannot claim unresolved Ethernet + // publisher identity solely from its MMS endpoint. + public string GooseEmptyEventTitle => GooseEvents.Count == 0 + ? "Waiting for the first meaningful GOOSE event" + : "No events match the selected IED"; + public System.Windows.Visibility GooseShowAllPublishersVisibility => + GooseIedScopeIndex == 0 && GooseEvents.Count > 0 && + !GooseVisibleEvents.Cast().Any() + ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + public string GooseEmptyEventDetail => GooseEvents.Count == 0 + ? "Frames are captured independently. New publishers and actual changes will appear here." + : $"Captured {GooseEvents.Count:N0} timeline event(s), but none has a verified publisher identity matching the selected IED. Choose All IED GOOSE signals to view them."; + public string GooseSelectedScopeHint => GooseIedScopeIndex == 1 ? "Display messages from every captured GOOSE publisher, including unresolved identities." : SelectedDevice is null @@ -58,6 +72,9 @@ private void RefreshGooseScopeViews() _gooseEventScopeView?.Refresh(); _gooseStreamScopeView?.Refresh(); Raise(nameof(GooseSelectedScopeHint)); + Raise(nameof(GooseEmptyEventTitle)); + Raise(nameof(GooseEmptyEventDetail)); + Raise(nameof(GooseShowAllPublishersVisibility)); _settingGooseLiveSelection = true; try { diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index f115c3a8b..d17409433 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -458,6 +458,9 @@ private void RaiseGoosePresentationState() { Raise(nameof(GoosePublisherCountText)); Raise(nameof(GooseEventCountText)); + Raise(nameof(GooseEmptyEventTitle)); + Raise(nameof(GooseEmptyEventDetail)); + Raise(nameof(GooseShowAllPublishersVisibility)); Raise(nameof(GooseSelectedLeafCountText)); Raise(nameof(GooseNoEventsVisibility)); } diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index d96d7e9d0..21a055141 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -21,6 +21,43 @@ public partial class MainWindow private readonly IedBrowserTreeProjection _iedBrowserTree = new(); private readonly IedBrowserDetailProjection _iedBrowserDetails = new(); private readonly IedBrowserNavigationTrail _iedBrowserNavigation = new(); + // Presentation preferences only. Never cache IEC values or MMS evidence. + // Exact DeviceId + stable page key, bounded to avoid accumulating transient IEDs. + private readonly Dictionary ExpandedKeys)> + _iedBrowserTreePreferences = new(StringComparer.Ordinal); + private readonly Dictionary> _iedBrowserSectionPreferences = + new(StringComparer.Ordinal); + private string? _iedBrowserRenderedSectionsKey; + + private void RememberIedBrowserTreePreferences() + { + if (_iedBrowserCachedDevice is null || _iedBrowserTree.Rows.Count == 0) + return; + var id = _iedBrowserCachedDevice.DeviceId; + if (_iedBrowserTreePreferences.Count >= 64 && + !_iedBrowserTreePreferences.ContainsKey(id)) + _iedBrowserTreePreferences.Remove(_iedBrowserTreePreferences.Keys.First()); + _iedBrowserTreePreferences[id] = ( + (IedBrowserTreeGrid.SelectedItem as IedBrowserTreeRow)?.Key, + _iedBrowserTree.ExpandedKeys()); + } + + private static string? BrowserSectionKey(Iec61850MonitorDevice? device, IedBrowserTreeRow? page) + => device is null || page is null ? null : device.DeviceId + "|" + page.Key; + + private void RememberIedBrowserSectionPreferences() + { + if (_iedBrowserRenderedSectionsKey is null || + !_iedBrowserDetails.Rows.Any(row => row.Kind == "Section" && row.HasChildren)) + return; + if (_iedBrowserSectionPreferences.Count >= 256 && + !_iedBrowserSectionPreferences.ContainsKey(_iedBrowserRenderedSectionsKey)) + _iedBrowserSectionPreferences.Remove(_iedBrowserSectionPreferences.Keys.First()); + _iedBrowserSectionPreferences[_iedBrowserRenderedSectionsKey] = + _iedBrowserDetails.Rows.Where(row => row.Kind == "Section" && + row.HasChildren && row.IsExpanded) + .Select(row => row.Name).ToHashSet(StringComparer.Ordinal); + } private bool _iedBrowserSyncingSelection; private string? _iedBrowserSelectedDoReference; private int _iedBrowserNavigationRenderTicket; @@ -2061,6 +2098,12 @@ private void RefreshIedBrowser() catch (IOException) { /* An unavailable source file cannot poison in-memory browsing. */ } catch (UnauthorizedAccessException) { /* Still show the cached/discovered model. */ } + // Checkpoint only on an actual IED/model replacement. On the ordinary + // reporting refresh path there is no need to scan the visible tree + // every 200 ms just to remember unchanged expansion preferences. + if (!ReferenceEquals(device, _iedBrowserCachedDevice) || + !ReferenceEquals(model, _iedBrowserCachedModel)) + RememberIedBrowserTreePreferences(); // Heavy schema indexing must never block the WPF selection dispatcher. if (!TryActivateIedBrowserSchema(device, model)) return; @@ -2078,12 +2121,13 @@ private void RefreshIedBrowser() var sameDevice = ReferenceEquals(device, _iedBrowserCachedDevice) && device.LiveValues.Association == _iedBrowserCachedAssociation && ReferenceEquals(model, _iedBrowserCachedModel); + _iedBrowserTreePreferences.TryGetValue(device.DeviceId, out var recalled); var priorKey = sameDevice ? (IedBrowserTreeGrid.SelectedItem as IedBrowserTreeRow)?.Key - : null; + : recalled.SelectedKey; var expandedKeys = sameDevice ? _iedBrowserTree.ExpandedKeys() - : new HashSet(StringComparer.Ordinal); + : recalled.ExpandedKeys ?? new HashSet(StringComparer.Ordinal); // Source inspection parses SCL XML; never perform it inside a click handler. if (!TryResolveIedBrowserSclInventory( @@ -2111,9 +2155,8 @@ private void RefreshIedBrowser() _iedBrowserRemoteFiles, EffectiveSclIedName(device), device.SclWorkspace?.DesignModel); _iedBrowserTree.Reset(roots, expandedKeys); - // Fresh IED/association/model epoch: all six Objects categories - // remain collapsed. Selecting Reports fills the right pane - // without materializing left LD/LN/RCB descendants. + // Restore only previously operator-expanded stable keys; no new + // branches are expanded automatically and no MMS reads are issued. IedBrowserTreeGrid.ItemsSource = _iedBrowserTree.Rows; var selected = priorKey == null ? null : _iedBrowserTree.FindVisible(priorKey); @@ -2326,7 +2369,14 @@ private void ReplaceIedBrowserDetails(IReadOnlyList rows) // Reset. A bound reset during routed selection can corrupt ItemInfo. IedBrowserDetailList.SelectedItem = null; IedBrowserDetailList.ItemsSource = null; - _iedBrowserDetails.Reset(rows); + RememberIedBrowserSectionPreferences(); + var nextKey = rows.Count == 0 ? null + : BrowserSectionKey(SelectedDevice, _iedBrowserRenderedPage); + var restored = nextKey is not null && + _iedBrowserSectionPreferences.TryGetValue(nextKey, out var expanded) + ? expanded : null; + _iedBrowserDetails.Reset(rows, restored); + _iedBrowserRenderedSectionsKey = nextKey; RebuildIedBrowserViewportIndex(); IedBrowserDetailList.ItemsSource = _iedBrowserDetails.Rows; } @@ -2342,6 +2392,7 @@ private void ToggleIedBrowserDetails(IedBrowserDetailRow row) IedBrowserDetailList.SelectedItem = null; IedBrowserDetailList.ItemsSource = null; _iedBrowserDetails.Toggle(row); + RememberIedBrowserSectionPreferences(); RebuildIedBrowserViewportIndex(); IedBrowserDetailList.ItemsSource = _iedBrowserDetails.Rows; _iedBrowserLastLiveVersion = -1; diff --git a/MainWindow.PersistentWorkbench.cs b/MainWindow.PersistentWorkbench.cs index 21139a39a..0ca46e9dc 100644 --- a/MainWindow.PersistentWorkbench.cs +++ b/MainWindow.PersistentWorkbench.cs @@ -43,6 +43,10 @@ private sealed class PersistentWorkbenchState // SV inspection is independent of MMS command admission. Default // collapsed, but retain the operator's explicit override per workspace. public bool SmvDockExpanded { get; set; } = false; + // Passive GOOSE observation gets the available timeline height by + // default. The operator can still expand MMS controls and the choice + // persists independently of the rest of the engineering workspaces. + public bool GooseDockExpanded { get; set; } = false; } [ModuleInitializer] @@ -386,6 +390,8 @@ private void CaptureP0CommandDockState(bool expanded) if (MainTabs.SelectedIndex == EngineeringNavigationRoute.SmvWorkspaceIndex) state.SmvDockExpanded = expanded; + else if (MainTabs.SelectedIndex == GooseSubscriberTabIndex) + state.GooseDockExpanded = expanded; else state.DockExpanded = expanded; } @@ -396,7 +402,10 @@ private void ApplyP0CommandDockState(PersistentWorkbenchState state, int workspa // MMS commands on passive SV inspection. Do not recreate the panel or // invoke any control/refresh operation as a navigation side effect. var desiredExpanded = workspaceIndex == EngineeringNavigationRoute.SmvWorkspaceIndex - ? state.SmvDockExpanded : state.DockExpanded; + ? state.SmvDockExpanded + : workspaceIndex == GooseSubscriberTabIndex + ? state.GooseDockExpanded + : state.DockExpanded; if (CommandPanelExpander.IsExpanded == desiredExpanded) return; diff --git a/Models/IedBrowserDetailSectionLayout.cs b/Models/IedBrowserDetailSectionLayout.cs index bef95664b..a14d6847c 100644 --- a/Models/IedBrowserDetailSectionLayout.cs +++ b/Models/IedBrowserDetailSectionLayout.cs @@ -58,7 +58,10 @@ public static IReadOnlyList Attach( result.Add(section with { LoadChildren = () => children, - ExpandByDefault = true + // Status summaries are rarely needed during routine browsing. + // Respect any explicit user toggle when the page is revisited. + ExpandByDefault = section.Name is not + ("Information received in last Report" or "Information received in last GOOSE") }); index = end - 1; } diff --git a/Models/IedBrowserPresentation.cs b/Models/IedBrowserPresentation.cs index b9e4bfb14..1b40ab6d5 100644 --- a/Models/IedBrowserPresentation.cs +++ b/Models/IedBrowserPresentation.cs @@ -348,7 +348,8 @@ public sealed class IedBrowserDetailProjection { public BulkObservableCollection Rows { get; } = new(); - public void Reset(IReadOnlyList rows) + public void Reset(IReadOnlyList rows, + IReadOnlySet? expandedSectionNames = null) { // Attach UI-only collapsibility to exact section boundaries. Do not // mutate source projection rows or create a second evidence store. @@ -357,7 +358,8 @@ public void Reset(IReadOnlyList rows) foreach (var row in sections) { display.Add(row); - if (!row.ExpandByDefault || !row.HasChildren) continue; + if (!row.HasChildren || (expandedSectionNames is null + ? !row.ExpandByDefault : !expandedSectionNames.Contains(row.Name))) continue; row.IsExpanded = true; display.AddRange(row.GetChildren()); } diff --git a/Services/SntpNetworkRouteResolver.cs b/Services/SntpNetworkRouteResolver.cs index cea95840d..fa4e0392c 100644 --- a/Services/SntpNetworkRouteResolver.cs +++ b/Services/SntpNetworkRouteResolver.cs @@ -11,6 +11,11 @@ public sealed record SntpNetworkBinding( string InterfaceName, string InterfaceId) { + // The shared WPF ComboBox selection template may render the selected + // object via ToString rather than DisplayMemberPath. Never expose the + // internal record type where the operator expects a local IPv4 address. + public override string ToString() => LocalAddress.ToString(); + public string Summary => DirectedBroadcast == null ? $"{InterfaceName} • {LocalAddress}" : $"{InterfaceName} • {LocalAddress} → {DirectedBroadcast}"; diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 3ea4030cb..908afa007 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -1,7 +1,7 @@ + MinWidth="900" MinHeight="0"> @@ -410,11 +410,16 @@ BorderBrush="#DCE6F3" BorderThickness="1" CornerRadius="11" Padding="15" HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="320"> - - +