diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs index 338a41b93..0cf330340 100644 --- a/MainWindow.ClockSyncToggle.cs +++ b/MainWindow.ClockSyncToggle.cs @@ -4,6 +4,7 @@ using System.Windows.Data; using System.Windows.Media; using System.Windows.Shapes; +using System.Windows.Media.Animation; using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -16,6 +17,12 @@ public partial class MainWindow private ToggleButton? _globalSntpToggle; private ComboBox? _globalSntpIpPicker; private Ellipse? _globalSntpStateDot; + private Ellipse? _globalSntpPulseRing; + private bool _globalSntpBreathing; + private long _globalSntpLastRenderedRequestCount; + private long _globalSntpLastRenderedReplyCount; + private DateTimeOffset _globalSntpSessionStartedUtc = DateTimeOffset.MaxValue; + private DateTimeOffset _globalSntpLastActivityPulseUtc = DateTimeOffset.MinValue; private SntpNetworkBinding? _selectedSntpBinding; internal bool IsClockSyncEnabled => _clockSyncEnabled; @@ -25,8 +32,19 @@ private void InstallGlobalSntpToggle() if (_globalSntpToggle is not null || WorkflowNavShell.Parent is not Grid headerGrid) return; + // Use the SAME shared ARSAS design tokens as the rest of the workstation. + // Only activity is animated; there is no timer, network polling, or + // UI thread callback dedicated to the NTP header. + var ink = (Brush)FindResource("Ink"); + var muted = (Brush)FindResource("Muted"); + var accent = (Brush)FindResource("Accent"); + var line = (Brush)FindResource("BorderStrong"); + var surface = (Brush)FindResource("SurfaceElevated"); + var font = (FontFamily)FindResource("AppFontFamily"); + var track = new FrameworkElementFactory(typeof(Border)); track.SetValue(Border.CornerRadiusProperty, new CornerRadius(12)); + track.SetValue(Border.BorderThicknessProperty, new Thickness(0)); track.SetBinding(Border.BackgroundProperty, new Binding(nameof(ToggleButton.Background)) { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) }); @@ -43,11 +61,12 @@ private void InstallGlobalSntpToggle() { Name = "GlobalSntpServerToggle", Width = 42, Height = 22, + VerticalAlignment = VerticalAlignment.Center, BorderThickness = new Thickness(0), HorizontalContentAlignment = HorizontalAlignment.Left, - Background = new SolidColorBrush(Color.FromRgb(153, 166, 181)), + Background = muted, Template = new ControlTemplate(typeof(ToggleButton)) { VisualTree = track }, - ToolTip = "Start or stop the independent SNTP service on the selected PC IP." + ToolTip = "Enable or disable the independent NTP Server." }; toggle.Checked += GlobalSntpToggle_Changed; toggle.Unchecked += GlobalSntpToggle_Changed; @@ -55,52 +74,107 @@ private void InstallGlobalSntpToggle() var label = new TextBlock { Text = "NTP Server", - FontSize = 11.4, - FontWeight = FontWeights.Medium, + FontFamily = font, + FontSize = 12.2, + FontWeight = FontWeights.SemiBold, + Foreground = ink, VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(7, 0, 10, 0) + Margin = new Thickness(9, 0, 12, 0) }; + var itemStyle = new Style(typeof(ComboBoxItem)); + itemStyle.Setters.Add(new Setter(Control.ForegroundProperty, ink)); + itemStyle.Setters.Add(new Setter(Control.FontFamilyProperty, font)); + itemStyle.Setters.Add(new Setter(Control.FontSizeProperty, 12.8)); + itemStyle.Setters.Add(new Setter(Control.FontWeightProperty, FontWeights.SemiBold)); + itemStyle.Setters.Add(new Setter(Control.PaddingProperty, new Thickness(9, 5, 9, 5))); + var picker = new ComboBox { Name = "GlobalSntpPcIpPicker", - Width = 145, Height = 27, + Width = 156, Height = 32, DisplayMemberPath = nameof(SntpNetworkBinding.LocalAddress), - Margin = new Thickness(0, 0, 10, 0), + FontFamily = font, + FontSize = 12.8, + FontWeight = FontWeights.SemiBold, + Foreground = ink, + Background = surface, + BorderBrush = line, + BorderThickness = new Thickness(1), + Padding = new Thickness(9, 3, 4, 3), + ItemContainerStyle = itemStyle, + Margin = new Thickness(0, 0, 11, 0), + VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center, - FontSize = 11, - ToolTip = "Choose a local PC IPv4 address. Refreshes when opened; no IED connection required." + ToolTip = "Select the PC's local IPv4 address." }; + TextOptions.SetTextFormattingMode(picker, TextFormattingMode.Display); + TextOptions.SetTextRenderingMode(picker, TextRenderingMode.ClearType); picker.DropDownOpened += (_, _) => RefreshGlobalSntpPcAddresses(); picker.SelectionChanged += GlobalSntpIp_SelectionChanged; + // One shared visual: steady center dot indicates serving; outer ring + // flashes only when a new NTP request or reply is actually observed. + var heartbeat = new Grid + { + Width = 22, Height = 24, + VerticalAlignment = VerticalAlignment.Center, + ToolTip = "NTP Server is stopped." + }; + var ring = new Ellipse + { + Width = 16, Height = 16, Opacity = 0, + Fill = Brushes.Transparent, + Stroke = accent, StrokeThickness = 1.6, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + RenderTransform = new ScaleTransform(1, 1), + RenderTransformOrigin = new Point(0.5, 0.5), + IsHitTestVisible = false + }; var led = new Ellipse { Name = "GlobalSntpTrafficIndicator", Width = 9, Height = 9, - Fill = Brushes.SlateGray, + Fill = muted, VerticalAlignment = VerticalAlignment.Center, - ToolTip = "No SNTP communication yet." + HorizontalAlignment = HorizontalAlignment.Center, + ToolTip = "NTP Server is stopped." }; + heartbeat.Children.Add(ring); + heartbeat.Children.Add(led); - var toolbar = new StackPanel + var contents = new StackPanel { - Name = "StandaloneSntpToolbar", Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center + }; + contents.Children.Add(toggle); + contents.Children.Add(label); + contents.Children.Add(picker); + contents.Children.Add(heartbeat); + var toolbar = new Border + { + Name = "StandaloneSntpToolbar", + Height = 42, + CornerRadius = new CornerRadius(10), + Background = surface, + BorderBrush = (Brush)FindResource("Line"), + BorderThickness = new Thickness(1), + Padding = new Thickness(11, 0, 11, 0), HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(8, 0, 0, 0) + Margin = new Thickness(9, 0, 0, 0), + Child = contents, + SnapsToDevicePixels = true }; - toolbar.Children.Add(toggle); - toolbar.Children.Add(label); - toolbar.Children.Add(picker); - toolbar.Children.Add(led); Grid.SetColumn(toolbar, 2); headerGrid.Children.Add(toolbar); _globalSntpToggle = toggle; _globalSntpIpPicker = picker; _globalSntpStateDot = led; + _globalSntpPulseRing = ring; RefreshGlobalSntpPcAddresses(); RefreshGlobalSntpToggle(_sntpClockService.Snapshot); } @@ -142,7 +216,10 @@ private async void GlobalSntpIp_SelectionChanged(object sender, SelectionChanged if (_globalSntpUiRefreshing || _globalSntpIpPicker is null) return; _selectedSntpBinding = _globalSntpIpPicker.SelectedItem as SntpNetworkBinding; if (_clockSyncEnabled) + { + BeginNtpVisualSession(); await ReconcileStandaloneClockAsync(); + } else PublishGlobalSntpUiState(); } @@ -157,10 +234,20 @@ private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e) internal async Task SetClockSyncEnabledAsync(bool enabled) { + if (enabled && !_clockSyncEnabled) + BeginNtpVisualSession(); _clockSyncEnabled = enabled && _selectedSntpBinding is not null; await ReconcileStandaloneClockAsync(); } + private void BeginNtpVisualSession() + { + var snapshot = _sntpClockService.Snapshot; + _globalSntpSessionStartedUtc = DateTimeOffset.UtcNow; + _globalSntpLastRenderedRequestCount = snapshot.ClientRequestCount; + _globalSntpLastRenderedReplyCount = snapshot.ReplyCount; + } + private void PublishGlobalSntpUiState() { var snapshot = _sntpClockService.Snapshot; @@ -178,38 +265,103 @@ private void RefreshGlobalSntpToggle(SntpClockServiceSnapshot snapshot) { if (_globalSntpToggle is null || _globalSntpIpPicker is null || _globalSntpStateDot is null) return; + _globalSntpUiRefreshing = true; try { _globalSntpToggle.IsChecked = _clockSyncEnabled; } finally { _globalSntpUiRefreshing = false; } - var serving = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Serving; - var fault = _clockSyncEnabled && snapshot.State is - SntpClockServiceState.Faulted or SntpClockServiceState.PortUnavailable; - _globalSntpToggle.Background = new SolidColorBrush(serving - ? Color.FromRgb(25, 164, 117) - : fault ? Color.FromRgb(218, 142, 36) - : _clockSyncEnabled ? Color.FromRgb(64, 124, 210) : Color.FromRgb(153, 166, 181)); + var state = SntpHeaderHeartbeatPolicy.Evaluate( + _clockSyncEnabled, snapshot, _globalSntpSessionStartedUtc, + _globalSntpLastRenderedRequestCount, _globalSntpLastRenderedReplyCount); + _globalSntpToggle.Background = _clockSyncEnabled + ? (Brush)FindResource("Accent") + : (Brush)FindResource("Muted"); _globalSntpToggle.HorizontalContentAlignment = _clockSyncEnabled ? HorizontalAlignment.Right : HorizontalAlignment.Left; - var exchanged = serving && snapshot.ReplyCount > 0; - var unanswered = serving && snapshot.ClientRequestCount > snapshot.ReplyCount; - _globalSntpStateDot.Fill = new SolidColorBrush( - fault ? Color.FromRgb(226, 79, 78) - : exchanged ? Color.FromRgb(16, 185, 129) - : unanswered ? Color.FromRgb(235, 162, 48) - : serving ? Color.FromRgb(64, 137, 228) - : Color.FromRgb(148, 163, 184)); - _globalSntpStateDot.ToolTip = - $"SNTP: {snapshot.State}\n" + + var dot = _globalSntpStateDot; + dot.Fill = state.Tone switch + { + SntpHeaderHeartbeatTone.Fault => (Brush)FindResource("Danger"), + SntpHeaderHeartbeatTone.Reply => (Brush)FindResource("Success"), + SntpHeaderHeartbeatTone.Request => (Brush)FindResource("Warning"), + SntpHeaderHeartbeatTone.Serving => (Brush)FindResource("Accent"), + _ => (Brush)FindResource("Muted") + }; + + // A single WPF composition clock performs the gentle running + // heartbeat. It is started/stopped on state transitions, never per + // packet; no DispatcherTimer, Thread.Sleep or background worker. + if (state.Serving != _globalSntpBreathing) + { + _globalSntpBreathing = state.Serving; + if (state.Serving) + { + dot.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation + { + From = 0.53, To = 1.0, + Duration = TimeSpan.FromMilliseconds(950), + AutoReverse = true, + RepeatBehavior = RepeatBehavior.Forever + }); + } + else + { + dot.BeginAnimation(UIElement.OpacityProperty, null); + dot.Opacity = 1; + if (_globalSntpPulseRing is { } ring) + { + ring.BeginAnimation(UIElement.OpacityProperty, null); + ring.Opacity = 0; + } + } + } + + // Status callbacks are already coalesced at the integration layer; + // avoid restarting the activity animation on rapid packet bursts. + var now = DateTimeOffset.UtcNow; + if (state.Serving && + (state.NewRequest || state.NewReply) && + now - _globalSntpLastActivityPulseUtc >= TimeSpan.FromMilliseconds(280)) + { + _globalSntpLastActivityPulseUtc = now; + FlashNtpTrafficRing(state.NewReply); + } + _globalSntpLastRenderedRequestCount = snapshot.ClientRequestCount; + _globalSntpLastRenderedReplyCount = snapshot.ReplyCount; + + var tooltip = + $"NTP: {snapshot.State}\n" + $"PC IP: {snapshot.Binding?.LocalAddress.ToString() ?? _selectedSntpBinding?.LocalAddress.ToString() ?? "—"}\n" + - $"Mode: {snapshot.TransportMode}\n" + + $"Transport: {snapshot.TransportMode}\n" + $"Requests: {snapshot.ClientRequestCount} · Replies: {snapshot.ReplyCount}\n" + $"Last reply: {snapshot.LastReplyUtc?.ToLocalTime().ToString("HH:mm:ss") ?? "—"}\n" + - "Replies show packet activity, not device clock synchronization."; + "Activity indicates packets, not proof of relay clock synchronization."; + dot.ToolTip = tooltip; + if (dot.Parent is FrameworkElement parent) + parent.ToolTip = tooltip; _globalSntpIpPicker.ToolTip = _selectedSntpBinding is null ? "Select an active PC IPv4 address." : $"{_selectedSntpBinding.InterfaceName} · {_selectedSntpBinding.LocalAddress}\n" + $"Broadcast: {_selectedSntpBinding.DirectedBroadcast?.ToString() ?? "N/A"}"; } + + private void FlashNtpTrafficRing(bool successfulReply) + { + if (_globalSntpPulseRing is not { } ring) + return; + ring.Stroke = (Brush)FindResource(successfulReply ? "Success" : "Warning"); + ring.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation + { + From = 0.95, To = 0, + Duration = TimeSpan.FromMilliseconds(720), + FillBehavior = FillBehavior.Stop + }); + if (ring.RenderTransform is ScaleTransform scale) + { + var duration = new Duration(TimeSpan.FromMilliseconds(720)); + scale.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(0.65, 1.55, duration)); + scale.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(0.65, 1.55, duration)); + } + } } diff --git a/Services/SntpHeaderHeartbeatPolicy.cs b/Services/SntpHeaderHeartbeatPolicy.cs new file mode 100644 index 000000000..c950609ee --- /dev/null +++ b/Services/SntpHeaderHeartbeatPolicy.cs @@ -0,0 +1,37 @@ +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// Pure, allocation-free translation of SNTP service telemetry into compact +/// header presentation. Never polls sockets and never claims relay sync. +/// +public enum SntpHeaderHeartbeatTone { Off, Serving, Request, Reply, Fault } + +public readonly record struct SntpHeaderHeartbeatState( + SntpHeaderHeartbeatTone Tone, bool Serving, bool NewRequest, bool NewReply); + +public static class SntpHeaderHeartbeatPolicy +{ + public static SntpHeaderHeartbeatState Evaluate( + bool enabled, SntpClockServiceSnapshot snapshot, DateTimeOffset visualSessionUtc, + long previouslyRenderedRequests, long previouslyRenderedReplies) + { + var serving = enabled && snapshot.State == SntpClockServiceState.Serving; + if (!enabled) + return new(SntpHeaderHeartbeatTone.Off, false, false, false); + if (snapshot.State is SntpClockServiceState.Faulted or SntpClockServiceState.PortUnavailable) + return new(SntpHeaderHeartbeatTone.Fault, false, false, false); + if (!serving) + return new(SntpHeaderHeartbeatTone.Off, false, false, false); + var requestActive = snapshot.LastRequestUtc >= visualSessionUtc; + var replyActive = snapshot.LastReplyUtc >= visualSessionUtc; + var newRequest = requestActive && snapshot.ClientRequestCount > previouslyRenderedRequests; + var newReply = replyActive && snapshot.ReplyCount > previouslyRenderedReplies; + return new( + replyActive ? SntpHeaderHeartbeatTone.Reply : + requestActive ? SntpHeaderHeartbeatTone.Request : + SntpHeaderHeartbeatTone.Serving, + true, newRequest, newReply); + } +} diff --git a/tests/ARSAS.Tests/SntpHeaderHeartbeatRegressionTests.cs b/tests/ARSAS.Tests/SntpHeaderHeartbeatRegressionTests.cs new file mode 100644 index 000000000..5edad6986 --- /dev/null +++ b/tests/ARSAS.Tests/SntpHeaderHeartbeatRegressionTests.cs @@ -0,0 +1,92 @@ +using ArIED61850Tester; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SntpHeaderHeartbeatRegressionTests +{ + private static SntpClockServiceSnapshot Snapshot( + SntpClockServiceState state, long requests = 0, long replies = 0, + DateTimeOffset? lastRequest = null, DateTimeOffset? lastReply = null) => + new(state, "", null, null, 0, true, SntpClockTransportMode.None, + 0, requests, replies, lastRequest, lastReply); + + [Fact] + public void Off_OrIdleServer_NeverInventsTraffic() + { + var began = DateTimeOffset.UtcNow; + var existing = Snapshot(SntpClockServiceState.Serving, 50, 40, + began.AddMinutes(-30), began.AddMinutes(-30)); + var off = SntpHeaderHeartbeatPolicy.Evaluate(false, existing, began, 0, 0); + Assert.Equal(SntpHeaderHeartbeatTone.Off, off.Tone); + Assert.False(off.Serving); + Assert.False(off.NewRequest); + Assert.False(off.NewReply); + var idle = SntpHeaderHeartbeatPolicy.Evaluate(true, existing, began, 50, 40); + Assert.Equal(SntpHeaderHeartbeatTone.Serving, idle.Tone); + Assert.False(idle.NewReply); + } + + [Fact] + public void RealPackets_ProduceOncePerUpdatedCounterAndDoNotTreatHistoryAsActivity() + { + var began = DateTimeOffset.UtcNow; + var request = Snapshot(SntpClockServiceState.Serving, 9, 4, began.AddMilliseconds(10), null); + var first = SntpHeaderHeartbeatPolicy.Evaluate(true, request, began, 8, 4); + Assert.Equal(SntpHeaderHeartbeatTone.Request, first.Tone); + Assert.True(first.NewRequest); + Assert.False(first.NewReply); + Assert.False(SntpHeaderHeartbeatPolicy.Evaluate(true, request, began, 9, 4).NewRequest); + + var reply = Snapshot(SntpClockServiceState.Serving, 9, 5, + began.AddMilliseconds(10), began.AddMilliseconds(12)); + var second = SntpHeaderHeartbeatPolicy.Evaluate(true, reply, began, 9, 4); + Assert.True(second.NewReply); + Assert.Equal(SntpHeaderHeartbeatTone.Reply, second.Tone); + Assert.False(SntpHeaderHeartbeatPolicy.Evaluate(true, reply, began, 9, 5).NewReply); + } + + [Fact] + public void FailedService_DoesNotImplySuccessfulClockSync() + { + var began = DateTimeOffset.UtcNow; + var faulted = SntpHeaderHeartbeatPolicy.Evaluate(true, + Snapshot(SntpClockServiceState.Faulted, 10, 10, began, began), began, 0, 0); + Assert.Equal(SntpHeaderHeartbeatTone.Fault, faulted.Tone); + Assert.False(faulted.Serving); + Assert.False(faulted.NewReply); + } + + [Fact] + public void Header_ReusesActualARSASTokensAndOneTimeWpfAnimations() + { + var source = Read("MainWindow.ClockSyncToggle.cs"); + foreach (var token in new[] { "Ink", "Muted", "Accent", "Line", "SurfaceElevated", "BorderStrong", "Success", "Warning", "Danger", "AppFontFamily" }) + Assert.Contains($"FindResource(\"{token}\")", source, StringComparison.Ordinal); + Assert.Contains("Name = \"GlobalSntpServerToggle\"", source, StringComparison.Ordinal); + Assert.Contains("Name = \"GlobalSntpPcIpPicker\"", source, StringComparison.Ordinal); + Assert.Contains("Name = \"GlobalSntpTrafficIndicator\"", source, StringComparison.Ordinal); + Assert.Contains("FontSize = 12.8", source, StringComparison.Ordinal); + Assert.Contains("FontWeights.SemiBold", source, StringComparison.Ordinal); + Assert.Contains("DoubleAnimation", source, StringComparison.Ordinal); + Assert.Contains("SntpHeaderHeartbeatPolicy.Evaluate", source, StringComparison.Ordinal); + Assert.Contains("state.NewRequest || state.NewReply", source, StringComparison.Ordinal); + Assert.Contains("_globalSntpBreathing", source, StringComparison.Ordinal); + Assert.DoesNotContain("new DispatcherTimer(", source, StringComparison.Ordinal); + Assert.DoesNotContain("new System.Threading.Timer(", source, StringComparison.Ordinal); + Assert.DoesNotContain("Task.Delay", source, StringComparison.Ordinal); + Assert.DoesNotContain("new Thread(", source, StringComparison.Ordinal); + } + + private static string Read(string path) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, path); + if (File.Exists(candidate)) return File.ReadAllText(candidate); + directory = directory.Parent; + } + throw new FileNotFoundException(path); + } +}