diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs index af2c2a58d..b517752ab 100644 --- a/MainWindow.ClockSync.cs +++ b/MainWindow.ClockSync.cs @@ -1,7 +1,6 @@ -using System.Collections.Specialized; -using System.ComponentModel; +using System.Collections.Concurrent; using System.Net; -using ArIED61850Tester.Models; +using System.Windows.Threading; using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -10,10 +9,12 @@ public partial class MainWindow { private readonly SntpClockService _sntpClockService = new(); private readonly SemaphoreSlim _clockSyncIntegrationGate = new(1, 1); - private readonly HashSet _clockSyncObservedClients = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _clockSyncRepliedClients = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _clockSyncObservedClients = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _clockSyncRepliedClients = new(StringComparer.OrdinalIgnoreCase); + private int _clockSyncUiRenderQueued; private string _lastClockSyncStatus = string.Empty; private bool _clockSyncLifecycleAttached; + private long _clockSyncDesiredVersion; internal event Action? ClockSyncSnapshotChanged; internal SntpClockServiceSnapshot ClockSyncSnapshot => _sntpClockService.Snapshot; @@ -22,203 +23,181 @@ private void InitializeClockSyncLifecycle() { if (_clockSyncLifecycleAttached) return; - _clockSyncLifecycleAttached = true; - InstallGlobalSntpToggle(); - - if (_clockSyncEnabled) - { - Devices.CollectionChanged += ClockSyncDevices_CollectionChanged; - foreach (var device in Devices) - AttachClockSyncDevice(device); - } _sntpClockService.StatusChanged += ClockSyncService_StatusChanged; _sntpClockService.ClientRequestObserved += ClockSyncService_ClientRequestObserved; _sntpClockService.ReplySent += ClockSyncService_ReplySent; Closed += ClockSyncMainWindow_Closed; + InstallGlobalSntpToggle(); PublishGlobalSntpUiState(); + // Deliberately no IED subscriptions: SNTP is an operator-controlled, + // standalone commissioning clock listening only after toggle ON. } - private void ClockSyncDevices_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + private async Task ReconcileStandaloneClockAsync() { - if (!_clockSyncEnabled) - return; - - if (e.OldItems != null) + var version = Interlocked.Increment(ref _clockSyncDesiredVersion); + try { - foreach (var item in e.OldItems.OfType()) - item.PropertyChanged -= ClockSyncDevice_PropertyChanged; + await _clockSyncIntegrationGate.WaitAsync(_applicationCancellation.Token); } - - if (e.NewItems != null) + catch (OperationCanceledException) { - foreach (var item in e.NewItems.OfType()) - AttachClockSyncDevice(item); - } - } - - private void AttachClockSyncDevice(Iec61850MonitorDevice device) - { - if (!_clockSyncEnabled) - return; - - device.PropertyChanged -= ClockSyncDevice_PropertyChanged; - device.PropertyChanged += ClockSyncDevice_PropertyChanged; - - if (device.IsConnected) - ScheduleClockSyncReconcile(device); - } - - private void ClockSyncDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (!_clockSyncEnabled || sender is not Iec61850MonitorDevice device || !device.IsConnected) return; - - if (e.PropertyName == nameof(Iec61850MonitorDevice.IsConnected) || - e.PropertyName == nameof(Iec61850MonitorDevice.IpAddress)) - { - ScheduleClockSyncReconcile(device); } - } - - private void ScheduleClockSyncReconcile(Iec61850MonitorDevice device) - { - if (!_clockSyncEnabled) - return; - if (!Dispatcher.CheckAccess()) + try { - Dispatcher.BeginInvoke(new Action(() => ScheduleClockSyncReconcile(device))); - return; - } + if (version != Volatile.Read(ref _clockSyncDesiredVersion)) + return; - _ = EnsureClockSyncForDeviceAsync(device); - } + if (!_clockSyncEnabled) + { + await _sntpClockService.StopAsync(); + _clockSyncObservedClients.Clear(); + _clockSyncRepliedClients.Clear(); + return; + } - private async Task EnsureClockSyncForDeviceAsync(Iec61850MonitorDevice device) - { - if (!_clockSyncEnabled || !device.IsConnected || - !IPAddress.TryParse(device.IpAddress, out var iedAddress) || - iedAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) - return; + var selected = _selectedSntpBinding; + if (selected is null) + { + _clockSyncEnabled = false; + await _sntpClockService.StopAsync(); + SetStatus("SNTP: select a PC IPv4 address."); + return; + } + + // Re-resolve after the async gate: a removed NIC or changed IP must + // never be served under an obsolete adapter identity. + var binding = SntpNetworkRouteResolver.ResolveForLocal( + selected.LocalAddress, selected.InterfaceId); + var active = _sntpClockService.Snapshot; + if (active.State == SntpClockServiceState.Serving && + active.Binding?.LocalAddress.Equals(binding.LocalAddress) == true && + active.Binding.InterfaceId.Equals(binding.InterfaceId, StringComparison.OrdinalIgnoreCase)) + return; - await _clockSyncIntegrationGate.WaitAsync(); - try - { - // Re-check after entering the integration gate so a queued connect event cannot - // restart SNTP after the operator has switched the global toggle off. - if (!_clockSyncEnabled || !device.IsConnected) + if (active.State != SntpClockServiceState.Stopped) + { + await _sntpClockService.StopAsync(); + _clockSyncObservedClients.Clear(); + _clockSyncRepliedClients.Clear(); + } + + if (version != Volatile.Read(ref _clockSyncDesiredVersion) || !_clockSyncEnabled) return; - await _sntpClockService.EnsureStartedAsync(iedAddress, _applicationCancellation.Token); - _sntpClockService.RequestImmediateBroadcast(); + await _sntpClockService.StartOnLocalAddressAsync( + binding.LocalAddress, binding.InterfaceId, _applicationCancellation.Token); } catch (OperationCanceledException) when (_applicationCancellation.IsCancellationRequested) { } catch (Exception ex) { - AddLog("WARN", "SNTP Server", - $"{device.Name}: IEC 61850 remains connected, but the global ARSAS SNTP Server could not start: {ex.Message}"); + // Only the current request may change desired state. A stale + // failed Start must not override a newer ON/OFF or IP selection. + if (version == Volatile.Read(ref _clockSyncDesiredVersion)) + { + _clockSyncEnabled = false; + try { await _sntpClockService.StopAsync(); } + catch (Exception stopError) + { + AddLog("WARN", "SNTP Server", $"Stop after failed bind: {stopError.Message}"); + } + AddLog("WARN", "SNTP Server", $"Cannot serve the selected PC IP: {ex.Message}"); + SetStatus("SNTP Server: check the selected PC IP."); + } } finally { _clockSyncIntegrationGate.Release(); + PublishGlobalSntpUiState(); } } - private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot snapshot) + private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot _) { - void Publish() + // One dispatcher operation per burst, regardless of how many IEDs + // query UDP/123 concurrently. The UI reads the newest state at flush. + if (Dispatcher.HasShutdownStarted) return; + if (Dispatcher.CheckAccess()) { - // Global telemetry receives every evidence-counter change even when the textual - // service detail did not change. FAT is only one passive consumer of this state. - RefreshGlobalSntpToggle(snapshot); - ClockSyncSnapshotChanged?.Invoke(snapshot); - - var status = $"{snapshot.State}|{snapshot.TransportMode}|{snapshot.Detail}"; - if (status.Equals(_lastClockSyncStatus, StringComparison.Ordinal)) - return; - - _lastClockSyncStatus = status; - var level = snapshot.State switch - { - SntpClockServiceState.Serving => "INFO", - SntpClockServiceState.Starting => "INFO", - SntpClockServiceState.Stopped => "INFO", - _ => "WARN" - }; - AddLog(level, "SNTP Server", snapshot.Detail); + RenderClockSyncStatus(_sntpClockService.Snapshot); + return; } + if (Interlocked.Exchange(ref _clockSyncUiRenderQueued, 1) != 0) + return; + Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + Interlocked.Exchange(ref _clockSyncUiRenderQueued, 0); + if (!Dispatcher.HasShutdownStarted) + RenderClockSyncStatus(_sntpClockService.Snapshot); + })); + } - if (Dispatcher.CheckAccess()) - Publish(); - else - Dispatcher.BeginInvoke(new Action(Publish)); + private void RenderClockSyncStatus(SntpClockServiceSnapshot snapshot) + { + RefreshGlobalSntpToggle(snapshot); + ClockSyncSnapshotChanged?.Invoke(snapshot); + var status = $"{snapshot.State}|{snapshot.TransportMode}|{snapshot.Detail}"; + if (status.Equals(_lastClockSyncStatus, StringComparison.Ordinal)) + return; + _lastClockSyncStatus = status; + AddLog(snapshot.State is SntpClockServiceState.Faulted or SntpClockServiceState.PortUnavailable + ? "WARN" : "INFO", "SNTP Server", snapshot.Detail); } private void ClockSyncService_ClientRequestObserved(SntpClientObservation observation) { var key = observation.Address.ToString(); - + // Deduplicate on the worker BEFORE posting to WPF. An IED querying + // once per second must not enqueue a UI log message every second. + if (!_clockSyncObservedClients.TryAdd(key, 0)) return; void Publish() { - if (!_clockSyncObservedClients.Add(key)) - return; - var device = Devices.FirstOrDefault(item => item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase)); - var name = device?.Name ?? key; AddLog("INFO", "SNTP Server", - $"{name} ({key}) sent an SNTPv{observation.Version} client request to ARSAS. Request observed; synchronization is not yet proven."); + $"{device?.Name ?? key} ({key}) sent an SNTPv{observation.Version} request. Clock synchronization is not yet proven."); } - - if (Dispatcher.CheckAccess()) - Publish(); - else - Dispatcher.BeginInvoke(new Action(Publish)); + if (Dispatcher.CheckAccess()) Publish(); + else if (!Dispatcher.HasShutdownStarted) Dispatcher.BeginInvoke(new Action(Publish)); } private void ClockSyncService_ReplySent(SntpReplyObservation observation) { var key = observation.Address.ToString(); - + if (!_clockSyncRepliedClients.TryAdd(key, 0)) return; void Publish() { - if (!_clockSyncRepliedClients.Add(key)) - return; - var device = Devices.FirstOrDefault(item => item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase)); - var name = device?.Name ?? key; - var transport = observation.TransportMode == SntpClockTransportMode.NpcapRaw ? "Npcap RAW" : "UDP"; AddLog("INFO", "SNTP Server", - $"{name} ({key}) received an ARSAS SNTP Mode 4 reply via {transport}. Reply sent; relay clock synchronization remains unproven until device evidence confirms it."); + $"{device?.Name ?? key} ({key}): SNTP Mode 4 reply sent. Device-side clock synchronization remains unproven."); } - - if (Dispatcher.CheckAccess()) - Publish(); - else - Dispatcher.BeginInvoke(new Action(Publish)); + if (Dispatcher.CheckAccess()) Publish(); + else if (!Dispatcher.HasShutdownStarted) Dispatcher.BeginInvoke(new Action(Publish)); } private async void ClockSyncMainWindow_Closed(object? sender, EventArgs e) { try { - Devices.CollectionChanged -= ClockSyncDevices_CollectionChanged; - foreach (var device in Devices) - device.PropertyChanged -= ClockSyncDevice_PropertyChanged; - + _clockSyncEnabled = false; + Interlocked.Increment(ref _clockSyncDesiredVersion); _sntpClockService.StatusChanged -= ClockSyncService_StatusChanged; _sntpClockService.ClientRequestObserved -= ClockSyncService_ClientRequestObserved; _sntpClockService.ReplySent -= ClockSyncService_ReplySent; - await _sntpClockService.DisposeAsync(); + await _clockSyncIntegrationGate.WaitAsync(); + try { await _sntpClockService.DisposeAsync(); } + finally { _clockSyncIntegrationGate.Release(); } } - catch + catch (Exception) { - // Application shutdown must never be blocked by a commissioning helper service. + // Do not block application shutdown on a commissioning utility. } } } diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs index 39598e3b3..338a41b93 100644 --- a/MainWindow.ClockSyncToggle.cs +++ b/MainWindow.ClockSyncToggle.cs @@ -1,262 +1,215 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; +using System.Windows.Data; using System.Windows.Media; using System.Windows.Shapes; -using ArIED61850Tester.Models; using ArIED61850Tester.Services; namespace ArIED61850Tester; public partial class MainWindow { - // Keep the existing commissioning default for compatibility, but make the state explicit - // in the global ARSAS chrome. The service is application-scoped: FAT never owns it. - private bool _clockSyncEnabled = true; + // Default OFF: no unsolicited station-bus broadcast before operator action. + private bool _clockSyncEnabled; + private bool _globalSntpUiRefreshing; private ToggleButton? _globalSntpToggle; + private ComboBox? _globalSntpIpPicker; private Ellipse? _globalSntpStateDot; - private TextBlock? _globalSntpCaption; - private bool _globalSntpToggleRefreshing; + private SntpNetworkBinding? _selectedSntpBinding; internal bool IsClockSyncEnabled => _clockSyncEnabled; private void InstallGlobalSntpToggle() { - if (_globalSntpToggle != null || WorkflowNavShell.Parent is not Grid headerGrid) + if (_globalSntpToggle is not null || WorkflowNavShell.Parent is not Grid headerGrid) return; - var stateDot = new Ellipse + var track = new FrameworkElementFactory(typeof(Border)); + track.SetValue(Border.CornerRadiusProperty, new CornerRadius(12)); + track.SetBinding(Border.BackgroundProperty, + new Binding(nameof(ToggleButton.Background)) + { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) }); + var knob = new FrameworkElementFactory(typeof(Ellipse)); + knob.SetValue(FrameworkElement.WidthProperty, 16d); + knob.SetValue(FrameworkElement.HeightProperty, 16d); + knob.SetValue(Shape.FillProperty, Brushes.White); + knob.SetValue(FrameworkElement.MarginProperty, new Thickness(3)); + knob.SetBinding(FrameworkElement.HorizontalAlignmentProperty, + new Binding(nameof(ToggleButton.HorizontalContentAlignment)) + { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) }); + track.AppendChild(knob); + var toggle = new ToggleButton { - Width = 8, - Height = 8, - Margin = new Thickness(0, 0, 8, 0), - VerticalAlignment = VerticalAlignment.Center, - Fill = Brushes.SlateGray + Name = "GlobalSntpServerToggle", + Width = 42, Height = 22, + BorderThickness = new Thickness(0), + HorizontalContentAlignment = HorizontalAlignment.Left, + Background = new SolidColorBrush(Color.FromRgb(153, 166, 181)), + Template = new ControlTemplate(typeof(ToggleButton)) { VisualTree = track }, + ToolTip = "Start or stop the independent SNTP service on the selected PC IP." }; - var caption = new TextBlock + toggle.Checked += GlobalSntpToggle_Changed; + toggle.Unchecked += GlobalSntpToggle_Changed; + + var label = new TextBlock { - Text = "SNTP Server", - FontSize = 11.3, - FontWeight = FontWeights.SemiBold, + Text = "NTP Server", + FontSize = 11.4, + FontWeight = FontWeights.Medium, VerticalAlignment = VerticalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis + Margin = new Thickness(7, 0, 10, 0) }; - var content = new StackPanel + + var picker = new ComboBox { - Orientation = Orientation.Horizontal, + Name = "GlobalSntpPcIpPicker", + Width = 145, Height = 27, + DisplayMemberPath = nameof(SntpNetworkBinding.LocalAddress), + Margin = new Thickness(0, 0, 10, 0), + VerticalContentAlignment = VerticalAlignment.Center, + FontSize = 11, + ToolTip = "Choose a local PC IPv4 address. Refreshes when opened; no IED connection required." + }; + picker.DropDownOpened += (_, _) => RefreshGlobalSntpPcAddresses(); + picker.SelectionChanged += GlobalSntpIp_SelectionChanged; + + var led = new Ellipse + { + Name = "GlobalSntpTrafficIndicator", + Width = 9, Height = 9, + Fill = Brushes.SlateGray, VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center + ToolTip = "No SNTP communication yet." }; - content.Children.Add(stateDot); - content.Children.Add(caption); - var toggle = new ToggleButton + var toolbar = new StackPanel { - Name = "GlobalSntpServerToggle", - MinWidth = 282, - MaxWidth = 340, - Height = 38, - Margin = new Thickness(12, 0, 0, 0), - Padding = new Thickness(12, 0, 12, 0), + Name = "StandaloneSntpToolbar", + Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - BorderThickness = new Thickness(1), - Background = new SolidColorBrush(Color.FromRgb(244, 247, 251)), - BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 234)), - Foreground = new SolidColorBrush(Color.FromRgb(82, 103, 126)), - Focusable = false, - IsThreeState = false, - Content = content + Margin = new Thickness(8, 0, 0, 0) }; - - toggle.Checked += GlobalSntpToggle_Changed; - toggle.Unchecked += GlobalSntpToggle_Changed; - Grid.SetColumn(toggle, 2); - headerGrid.Children.Add(toggle); + 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; - _globalSntpStateDot = stateDot; - _globalSntpCaption = caption; + _globalSntpIpPicker = picker; + _globalSntpStateDot = led; + RefreshGlobalSntpPcAddresses(); RefreshGlobalSntpToggle(_sntpClockService.Snapshot); } - private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e) + private void RefreshGlobalSntpPcAddresses() { - if (_globalSntpToggleRefreshing || _globalSntpToggle == null) + if (_globalSntpIpPicker is null) return; + IReadOnlyList choices; + try { choices = SntpNetworkRouteResolver.GetLocalBindings(); } + catch (Exception ex) + { + AddLog("WARN", "SNTP Server", $"Could not enumerate PC IPv4 addresses: {ex.Message}"); return; + } - _globalSntpToggle.IsEnabled = false; + var previous = _selectedSntpBinding; + var matched = choices.FirstOrDefault(item => + previous is not null && + item.LocalAddress.Equals(previous.LocalAddress) && + item.InterfaceId.Equals(previous.InterfaceId, StringComparison.OrdinalIgnoreCase)); + // An active server whose NIC vanished is stopped, never silently + // moved onto another PC network (especially a different station LAN). + var selected = matched ?? (previous is null && !_clockSyncEnabled ? choices.FirstOrDefault() : null); + _globalSntpUiRefreshing = true; try { - await SetClockSyncEnabledAsync(_globalSntpToggle.IsChecked == true); - } - finally - { - _globalSntpToggle.IsEnabled = true; + _globalSntpIpPicker.ItemsSource = choices; + _globalSntpIpPicker.SelectedItem = selected; + _selectedSntpBinding = selected; } + finally { _globalSntpUiRefreshing = false; } + + if (_clockSyncEnabled && selected is null) + _ = ReconcileStandaloneClockAsync(); } - internal async Task SetClockSyncEnabledAsync(bool enabled) + private async void GlobalSntpIp_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (_clockSyncEnabled == enabled) - { - if (enabled) - _sntpClockService.RequestImmediateBroadcast(); - PublishGlobalSntpUiState(); - return; - } - - _clockSyncEnabled = enabled; - PublishGlobalSntpUiState(); - - if (!enabled) - { - Devices.CollectionChanged -= ClockSyncDevices_CollectionChanged; - foreach (var device in Devices) - device.PropertyChanged -= ClockSyncDevice_PropertyChanged; - - await _clockSyncIntegrationGate.WaitAsync(); - try - { - await _sntpClockService.StopAsync(); - _clockSyncObservedClients.Clear(); - _clockSyncRepliedClients.Clear(); - AddLog("INFO", "SNTP Server", - "Global SNTP Server disabled. IEC 61850 monitoring and FAT sessions remain active."); - } - catch (Exception ex) - { - AddLog("WARN", "SNTP Server", - $"Global SNTP Server disable requested, but the clock service reported: {ex.Message}"); - } - finally - { - _clockSyncIntegrationGate.Release(); - } - + if (_globalSntpUiRefreshing || _globalSntpIpPicker is null) return; + _selectedSntpBinding = _globalSntpIpPicker.SelectedItem as SntpNetworkBinding; + if (_clockSyncEnabled) + await ReconcileStandaloneClockAsync(); + else PublishGlobalSntpUiState(); - return; - } + } - Devices.CollectionChanged -= ClockSyncDevices_CollectionChanged; - Devices.CollectionChanged += ClockSyncDevices_CollectionChanged; - foreach (var device in Devices) - AttachClockSyncDevice(device); + private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e) + { + if (_globalSntpUiRefreshing || _globalSntpToggle is null) return; + // Keep the switch interactive even while a slow Npcap fallback starts. + // Serialized lifecycle + versioned intent safely handle rapid ON/OFF. + await SetClockSyncEnabledAsync(_globalSntpToggle.IsChecked == true); + } - AddLog("INFO", "SNTP Server", - "Global SNTP Server enabled. It remains active independently of FAT while ARSAS is running. Connected IPv4 IEDs select the station-bus interface; ARSAS serves UDP/123 and broadcasts Mode 5 time, with Npcap RAW fallback when Windows already owns UDP/123. Windows Time is never stopped or reconfigured."); - PublishGlobalSntpUiState(); + internal async Task SetClockSyncEnabledAsync(bool enabled) + { + _clockSyncEnabled = enabled && _selectedSntpBinding is not null; + await ReconcileStandaloneClockAsync(); } private void PublishGlobalSntpUiState() { var snapshot = _sntpClockService.Snapshot; - void Publish() { RefreshGlobalSntpToggle(snapshot); ClockSyncSnapshotChanged?.Invoke(snapshot); } - - if (Dispatcher.CheckAccess()) - Publish(); - else + if (Dispatcher.CheckAccess()) Publish(); + else if (!Dispatcher.HasShutdownStarted) Dispatcher.BeginInvoke(new Action(Publish)); } private void RefreshGlobalSntpToggle(SntpClockServiceSnapshot snapshot) { - if (_globalSntpToggle == null || _globalSntpCaption == null || _globalSntpStateDot == null) + if (_globalSntpToggle is null || _globalSntpIpPicker is null || _globalSntpStateDot is null) return; - - _globalSntpToggleRefreshing = true; - try - { - _globalSntpToggle.IsChecked = _clockSyncEnabled; - } - finally - { - _globalSntpToggleRefreshing = false; - } - - var localAddress = snapshot.Binding?.LocalAddress.ToString(); - var isServing = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Serving; - var isStarting = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Starting; - var isFault = _clockSyncEnabled && snapshot.State is SntpClockServiceState.Faulted or SntpClockServiceState.PortUnavailable; - - _globalSntpCaption.Text = !_clockSyncEnabled - ? "SNTP Server Off" - : isServing - ? $"SNTP Server Active: {localAddress ?? "station bus"}" - : isStarting - ? "SNTP Server Starting…" - : isFault - ? "SNTP Server Attention" - : "SNTP Server Enabled · waiting for IED"; - - if (isServing) - { - _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(236, 253, 245)); - _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(167, 243, 208)); - _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(4, 120, 87)); - _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(16, 185, 129)); - } - else if (isFault) - { - _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(255, 248, 230)); - _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(242, 210, 138)); - _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(148, 98, 0)); - _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(245, 158, 11)); - } - else if (_clockSyncEnabled) - { - _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(238, 244, 255)); - _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(201, 217, 241)); - _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(69, 100, 142)); - _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(47, 128, 237)); - } - else - { - _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(244, 247, 251)); - _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 234)); - _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(82, 103, 126)); - _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(148, 163, 184)); - } - - _globalSntpToggle.ToolTip = BuildGlobalSntpToolTip(snapshot); - } - - private string BuildGlobalSntpToolTip(SntpClockServiceSnapshot snapshot) - { - var binding = snapshot.Binding; - var local = binding?.LocalAddress.ToString() ?? "waiting for first connected IPv4 IED"; - var broadcast = binding?.DirectedBroadcast?.ToString() ?? "—"; - var adapter = binding?.InterfaceName ?? "—"; - var transport = snapshot.TransportMode switch - { - SntpClockTransportMode.UdpSocket => "UDP/123", - SntpClockTransportMode.NpcapRaw => "Npcap RAW", - _ => "—" - }; - var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; - var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; - var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; - - return $"Global ARSAS SNTP Server\n" + - $"Enabled: {_clockSyncEnabled}\n" + - $"State: {snapshot.State}\n" + - $"Local server IP: {local}\n" + - $"Station-bus adapter: {adapter}\n" + - $"Directed broadcast: {broadcast}\n" + - $"Transport: {transport}\n" + - $"Mode 5 broadcasts: {snapshot.BroadcastCount} (last {lastBroadcast})\n" + - $"Client requests observed: {snapshot.ClientRequestCount} (last {lastRequest})\n" + - $"Mode 4 replies sent: {snapshot.ReplyCount} (last {lastReply})\n\n" + - "When enabled, this service is global and continues outside FAT until disabled or ARSAS closes. " + - "A sent broadcast or reply proves SNTP packet activity, not that an IED accepted or locked its internal clock. " + - "Relay/event timestamps are trustworthy only after device-side synchronization evidence confirms the clock.\n\n" + - snapshot.Detail; + _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)); + _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" + + $"PC IP: {snapshot.Binding?.LocalAddress.ToString() ?? _selectedSntpBinding?.LocalAddress.ToString() ?? "—"}\n" + + $"Mode: {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."; + _globalSntpIpPicker.ToolTip = _selectedSntpBinding is null + ? "Select an active PC IPv4 address." + : $"{_selectedSntpBinding.InterfaceName} · {_selectedSntpBinding.LocalAddress}\n" + + $"Broadcast: {_selectedSntpBinding.DirectedBroadcast?.ToString() ?? "N/A"}"; } } diff --git a/MainWindow.NativeFatDiagnostics.cs b/MainWindow.NativeFatDiagnostics.cs index f5e5960e5..92b231b6e 100644 --- a/MainWindow.NativeFatDiagnostics.cs +++ b/MainWindow.NativeFatDiagnostics.cs @@ -277,8 +277,8 @@ private string BuildNativeFatTimeSyncDiagnosticText( builder.AppendLine($"Broadcasts : {snapshot.BroadcastCount}"); builder.AppendLine($"Client requests : {snapshot.ClientRequestCount}"); builder.AppendLine($"Replies sent : {snapshot.ReplyCount}"); - builder.AppendLine($"Selected IED request observed: {_clockSyncObservedClients.Contains(device.IpAddress)}"); - builder.AppendLine($"Selected IED reply sent : {_clockSyncRepliedClients.Contains(device.IpAddress)}"); + builder.AppendLine($"Selected IED request observed: {_clockSyncObservedClients.ContainsKey(device.IpAddress)}"); + builder.AppendLine($"Selected IED reply sent : {_clockSyncRepliedClients.ContainsKey(device.IpAddress)}"); builder.AppendLine(); builder.AppendLine("SNTP server activity, request/reply counters and a positive TimeSynchrnz value are supporting evidence only."); builder.AppendLine("They never grant 'Time Sync OK' without the device-side LTMS/timestamp evidence evaluated above."); diff --git a/Services/SntpClockService.cs b/Services/SntpClockService.cs index 763b1ce8d..84815bab9 100644 --- a/Services/SntpClockService.cs +++ b/Services/SntpClockService.cs @@ -113,6 +113,39 @@ public SntpClockServiceSnapshot Snapshot public IReadOnlyCollection ObservedClients => _clients.Values.OrderBy(item => item.Address.ToString(), StringComparer.OrdinalIgnoreCase).ToArray(); + /// + /// Independent PC-address listener. The calling application controls + /// adapter switching through an explicit serialized Stop/Start operation. + /// This entry point does not inspect IEDs or MMS associations. + /// + public async Task StartOnLocalAddressAsync( + IPAddress localAddress, string interfaceId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(localAddress); + ArgumentException.ThrowIfNullOrWhiteSpace(interfaceId); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var requested = SntpNetworkRouteResolver.ResolveForLocal(localAddress, interfaceId); + if ((_udp != null || _rawTransport != null) && _binding is not null) + { + if (_binding.LocalAddress.Equals(requested.LocalAddress) && + _binding.InterfaceId.Equals(requested.InterfaceId, StringComparison.OrdinalIgnoreCase)) + return; + + throw new InvalidOperationException("Stop the active SNTP server before selecting another PC IP address."); + } + + await StartCoreAsync(requested, cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + // Retained as compatibility for older clients; the standalone ARSAS + // lifecycle uses StartOnLocalAddressAsync instead. public async Task EnsureStartedAsync(IPAddress iedAddress, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(iedAddress); diff --git a/Services/SntpNetworkRouteResolver.cs b/Services/SntpNetworkRouteResolver.cs index 49b95d457..cea95840d 100644 --- a/Services/SntpNetworkRouteResolver.cs +++ b/Services/SntpNetworkRouteResolver.cs @@ -21,6 +21,45 @@ public sealed record SntpNetworkBinding( /// public static class SntpNetworkRouteResolver { + /// + /// Enumerates the PC's actual active IPv4 unicast addresses. Never uses an + /// IED endpoint as a substitute for a server listen address. Keeps aliases. + /// + public static IReadOnlyList GetLocalBindings() + => GetIpv4Candidates() + .Select(Build) + .OrderBy(item => item.InterfaceName, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.LocalAddress.ToString(), StringComparer.Ordinal) + .ToArray(); + + /// + /// Re-checks the chosen address and its adapter immediately before binding. + /// Detects disconnected NICs, stale saved IPs and moved alias addresses. + /// + public static SntpNetworkBinding ResolveForLocal(IPAddress localAddress, string? interfaceId = null) + => SelectLocalBinding(localAddress, interfaceId, GetLocalBindings()); + + /// + /// Pure canonical IP+NIC selector so alias and duplicate-interface cases + /// can be unit-tested without touching the host network or UDP port. + /// + public static SntpNetworkBinding SelectLocalBinding( + IPAddress localAddress, string? interfaceId, IReadOnlyList available) + { + ArgumentNullException.ThrowIfNull(localAddress); + ArgumentNullException.ThrowIfNull(available); + if (localAddress.AddressFamily != AddressFamily.InterNetwork || + IPAddress.IsLoopback(localAddress) || localAddress.Equals(IPAddress.Any)) + throw new ArgumentException("Select an active non-loopback PC IPv4 address.", nameof(localAddress)); + var matches = available + .Where(item => item.LocalAddress.Equals(localAddress) && + (interfaceId is null || item.InterfaceId.Equals(interfaceId, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + if (matches.Length != 1) + throw new InvalidOperationException($"PC address {localAddress} is not uniquely assigned to an active network interface. Refresh the IP list."); + return matches[0]; + } + public static SntpNetworkBinding ResolveForRemote(IPAddress remoteAddress) { ArgumentNullException.ThrowIfNull(remoteAddress); @@ -117,9 +156,6 @@ private static List GetIpv4Candidates() } } - if (candidates.Count == 0) - throw new InvalidOperationException("No active IPv4 station-bus network adapter is available."); - return candidates; } diff --git a/docs/SNTP_CLOCK_SYNC.md b/docs/SNTP_CLOCK_SYNC.md index 530418f88..14122c40a 100644 --- a/docs/SNTP_CLOCK_SYNC.md +++ b/docs/SNTP_CLOCK_SYNC.md @@ -2,17 +2,24 @@ ARSAS includes a small clean-room SNTPv4 commissioning service for station-bus work. The implementation is written specifically for ARSAS from the protocol behavior described by RFC 4330 and RFC 5905; it does not embed or copy a third-party NTP implementation. +## Minimal operator UI + +The compact main header contains a **NTP Server toggle**, a **dropdown of active PC IPv4 addresses**, and an **SNTP communication LED**. Technical request/reply counters, selected NIC and UTC reference limitations are provided as tooltips instead of permanent GUI text. Blue means listening, green means a reply was sent during the current session, amber means a pending request or service problem, and grey means stopped. No LED state alone proves that a relay is synchronized. + +The lifecycle is independent of all IED sessions and uses C#/.NET async UDP, with the existing optional Npcap RAW fallback retained. No new native code, Windows service, or Windows Time configuration change is introduced. UI dispatcher notifications are coalesced, and per-client log notifications deduplicated before dispatch. + ## Current behavior -- Starts automatically after the first IPv4 IED reaches `IsConnected = true` while the FAT `Clock Sync` checkbox is enabled. -- Uses the Windows route to that IED to select the station-bus IPv4 interface. +- Starts only when the operator turns **NTP Server ON** in the main header, independently of IED/MMS/Discovery/FAT; it defaults OFF. +- Lists all active non-loopback PC IPv4 addresses, including multiple aliases on an adapter, for explicit server IP selection. +- Revalidates IP and network-adapter identity before binding; serialized rebind on IP change and fail-closed on ambiguous or missing addresses. - Prefers a normal UDP/123 socket bound only to that local interface. - If Windows Time or another process already owns UDP/123, automatically falls back to raw Ethernet capture/injection through Npcap on the same station-bus adapter. - Never stops, restarts, or reconfigures Windows Time. - Replies to SNTPv3/v4 client Mode 3 requests with server Mode 4. - Copies the client Version, Poll, and Transmit timestamp into the reply fields required by SNTP server semantics. - Sends an immediate SNTPv4 Mode 5 directed broadcast, then repeats every 64 seconds by default when a usable directed-broadcast address exists. -- Sends another immediate broadcast when a newly connected IED is observed. +- No IED connection is needed for broadcast or unicast service; its startup broadcast occurs only after explicit operator enablement. - Separately records `broadcast sent`, `client request seen`, and `Mode 4 reply sent` evidence. - Advertises synchronized commissioning packets with commissioning compatibility `stratum 2` and reference ID `LOCL`. - Performs a wall-clock sanity/step check. A large time step suppresses broadcast and makes that instant's unicast reply RFC-style unsynchronized (`LI=3`, `stratum=0`, `INIT`, server timestamps zero). @@ -63,9 +70,9 @@ Npcap is therefore optional for the normal UDP path but required for the RAW fal ## Network scope -ARSAS serves the first station-bus interface selected by Windows routing. IEDs on that subnet can use unicast SNTP by configuring the ARSAS laptop station-bus IP as the server, or Mode 5 broadcast when the relay configuration supports broadcast-client operation. +ARSAS binds to the **local PC IPv4 address selected in the toolbar**, not the route to a connected IED. A relay may request unicast time by using that address, regardless of whether it has an MMS association with ARSAS. An optional Mode 5 directed broadcast is emitted only after the operator switches the service ON. -If another connected IED routes through a different local IPv4 interface, ARSAS reports that the existing clock service remains on the original station-bus binding rather than silently moving the clock source. +Changing the selected local IP while enabled serializes Stop → fresh interface validation → Start. An IP removed from the computer cannot silently migrate the service to another station LAN. One ARSAS SNTP transport is active at a time; switching OFF stops it, and ARSAS closing disposes the service. ## Validation @@ -89,3 +96,7 @@ If another connected IED routes through a different local IPv4 interface, ARSAS - preservation of a single VLAN tag; - raw Mode 5 Ethernet/directed-broadcast construction; - rejection of non-Mode-3 or wrong-destination-port traffic. + +## Standalone lifecycle regression + +`GlobalSntpLifecycleRegressionTests` verifies the compact PC IP picker and independent lifecycle; `SntpLocalBindingTests` verifies alias selection on one NIC, rejection of stale/duplicate adapter addresses, loopback and unspecified IP guards. Windows CI also checks portable publish and smoke tests. Physical acceptance remains necessary: with zero connected IEDs, toggle ON, receive an SNTP Mode 3 client request, send a Mode 4 reply, toggle OFF, and verify UDP/123 is no longer held by ARSAS.\n \ No newline at end of file diff --git a/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs b/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs index eb7b3650e..200227cf5 100644 --- a/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs +++ b/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs @@ -1,70 +1,89 @@ +using System.Net; +using ArIED61850Tester.Services; + namespace ARSAS.Tests; public sealed class GlobalSntpLifecycleRegressionTests { [Fact] - public void MainHeader_ExposesGlobalSntpToggleWithActualBoundServerIp() + public void StandaloneUi_IsOnlyToggleLocalIpAndTrafficIndicator() { - var source = File.ReadAllText(FindRepoFile("MainWindow.ClockSyncToggle.cs")); - - Assert.Contains("WorkflowNavShell.Parent is not Grid headerGrid", source, StringComparison.Ordinal); - Assert.Contains("Name = \"GlobalSntpServerToggle\"", source, StringComparison.Ordinal); - Assert.Contains("Grid.SetColumn(toggle, 2)", source, StringComparison.Ordinal); - Assert.Contains("SNTP Server Active:", source, StringComparison.Ordinal); - Assert.Contains("snapshot.Binding?.LocalAddress", source, StringComparison.Ordinal); - Assert.Contains("Directed broadcast", source, StringComparison.Ordinal); - Assert.Contains("continues outside FAT", source, StringComparison.Ordinal); + var ui = Read("MainWindow.ClockSyncToggle.cs"); + Assert.Contains("Name = \"GlobalSntpServerToggle\"", ui, StringComparison.Ordinal); + Assert.Contains("Name = \"GlobalSntpPcIpPicker\"", ui, StringComparison.Ordinal); + Assert.Contains("Name = \"GlobalSntpTrafficIndicator\"", ui, StringComparison.Ordinal); + Assert.Contains("Text = \"NTP Server\"", ui, StringComparison.Ordinal); + Assert.Contains("FrameworkElementFactory(typeof(Ellipse))", ui, StringComparison.Ordinal); + Assert.Contains("SntpNetworkRouteResolver.GetLocalBindings()", ui, StringComparison.Ordinal); + Assert.DoesNotContain("waiting for IED", ui, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("MinWidth = 282", ui, StringComparison.Ordinal); } [Fact] - public void GlobalLifecycle_FollowsConnectedIedsAndCannotRestartAfterToggleOff() + public void StandaloneLifecycle_IsIndependentOfAllIedConnectionEvents() { - var source = File.ReadAllText(FindRepoFile("MainWindow.ClockSync.cs")); - - Assert.Contains("Devices.CollectionChanged += ClockSyncDevices_CollectionChanged", source, StringComparison.Ordinal); - Assert.Contains("device.PropertyChanged += ClockSyncDevice_PropertyChanged", source, StringComparison.Ordinal); - Assert.Contains("EnsureStartedAsync(iedAddress", source, StringComparison.Ordinal); - Assert.Contains("if (!_clockSyncEnabled || !device.IsConnected)", source, StringComparison.Ordinal); + var source = Read("MainWindow.ClockSync.cs"); + var ui = Read("MainWindow.ClockSyncToggle.cs"); + Assert.Contains("ReconcileStandaloneClockAsync()", source, StringComparison.Ordinal); + Assert.Contains("StartOnLocalAddressAsync(", source, StringComparison.Ordinal); + Assert.Contains("SntpNetworkRouteResolver.ResolveForLocal(", source, StringComparison.Ordinal); + Assert.Contains("Interlocked.Increment(ref _clockSyncDesiredVersion)", source, StringComparison.Ordinal); + Assert.Contains("await _sntpClockService.StopAsync()", source, StringComparison.Ordinal); + Assert.DoesNotContain("ClockSyncDevices_CollectionChanged", source, StringComparison.Ordinal); + Assert.DoesNotContain("ClockSyncDevice_PropertyChanged", source, StringComparison.Ordinal); + Assert.DoesNotContain("ScheduleClockSyncReconcile", source, StringComparison.Ordinal); + Assert.DoesNotContain("EnsureStartedAsync(iedAddress", source, StringComparison.Ordinal); + Assert.Contains("private bool _clockSyncEnabled;", ui, StringComparison.Ordinal); + Assert.Contains("SetClockSyncEnabledAsync(bool enabled)", ui, StringComparison.Ordinal); + Assert.Contains("if (_clockSyncEnabled && selected is null)", ui, StringComparison.Ordinal); Assert.Contains("Closed += ClockSyncMainWindow_Closed", source, StringComparison.Ordinal); Assert.Contains("await _sntpClockService.DisposeAsync()", source, StringComparison.Ordinal); } [Fact] - public void FatWorkspace_IsPassiveConsumerOfGlobalSntpState() + public void LocalSelection_MustBeLiveAndUnambiguous() { - var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.ClockSyncUx.cs")); - - Assert.Contains("Global SNTP is controlled from the ARSAS header", source, StringComparison.Ordinal); - Assert.Contains("continues running when the FAT window closes", source, StringComparison.Ordinal); - Assert.DoesNotContain("new CheckBox", source, StringComparison.Ordinal); - Assert.DoesNotContain("SetClockSyncEnabledAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("ClockSyncCheckBox_Changed", source, StringComparison.Ordinal); + var resolver = Read("Services/SntpNetworkRouteResolver.cs"); + Assert.Contains("GetLocalBindings()", resolver, StringComparison.Ordinal); + Assert.Contains("ResolveForLocal(IPAddress localAddress, string? interfaceId = null)", resolver, StringComparison.Ordinal); + Assert.Contains("matches.Length != 1", resolver, StringComparison.Ordinal); + Assert.Contains("OperationalStatus.Up", resolver, StringComparison.Ordinal); + Assert.Contains("NetworkInterfaceType.Loopback", resolver, StringComparison.Ordinal); + Assert.Contains("UnicastAddresses", resolver, StringComparison.Ordinal); + Assert.Contains("item.InterfaceId.Equals(interfaceId", resolver, StringComparison.Ordinal); } [Fact] - public void GlobalService_RemainsARealUdp123ServerAndMode5Broadcaster() + public void ExistingTransport_RemainsUdp123WithReadOnlyFallbackAndClockGuard() { - var source = File.ReadAllText(FindRepoFile("Services/SntpClockService.cs")); - + var source = Read("Services/SntpClockService.cs"); + Assert.Contains("StartOnLocalAddressAsync(", source, StringComparison.Ordinal); Assert.Contains("new IPEndPoint(binding.LocalAddress, 123)", source, StringComparison.Ordinal); Assert.Contains("SntpPacket.BuildServerReply", source, StringComparison.Ordinal); Assert.Contains("SntpPacket.BuildBroadcast", source, StringComparison.Ordinal); - Assert.Contains("Mode 5 broadcast", source, StringComparison.Ordinal); - Assert.Contains("SendBroadcastPacketAsync", source, StringComparison.Ordinal); - Assert.Contains("TimeSpan.FromSeconds(64)", source, StringComparison.Ordinal); + Assert.Contains("StartRawFallbackAsync(", source, StringComparison.Ordinal); + Assert.Contains("RequestImmediateBroadcast()", source, StringComparison.Ordinal); + Assert.Contains("ClockHealthSample", source, StringComparison.Ordinal); } - private static string FindRepoFile(string relativePath) + [Fact] + public void FatWorkspace_RemainsPassiveConsumer() + { + var source = Read("IoListTestingWindow.ClockSyncUx.cs"); + Assert.Contains("Global SNTP is controlled from the ARSAS header", source, StringComparison.Ordinal); + Assert.Contains("continues running when the FAT window closes", source, StringComparison.Ordinal); + Assert.DoesNotContain("SetClockSyncEnabledAsync", source, StringComparison.Ordinal); + } + + private static string Read(string path) { DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory != null) + while (directory is not null) { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - return candidate; + var candidate = Path.Combine(directory.FullName, path); + if (File.Exists(candidate)) return File.ReadAllText(candidate); directory = directory.Parent; } - - throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + throw new FileNotFoundException(path); } } diff --git a/tests/ARSAS.Tests/SntpLocalBindingTests.cs b/tests/ARSAS.Tests/SntpLocalBindingTests.cs new file mode 100644 index 000000000..2d741fe11 --- /dev/null +++ b/tests/ARSAS.Tests/SntpLocalBindingTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SntpLocalBindingTests +{ + private static readonly IPAddress Mask = IPAddress.Parse("255.255.255.0"); + + private static SntpNetworkBinding Binding(string ip, string nic) => + new(IPAddress.Parse(ip), Mask, null, nic, nic); + + [Fact] + public void SelectedIp_ResolvesAliasOnSameAdapterWithoutGuessingFirstAddress() + { + var bindings = new[] + { + Binding("192.16.1.33", "LAN"), + Binding("192.16.1.115", "LAN"), + Binding("192.168.1.7", "WiFi") + }; + var chosen = SntpNetworkRouteResolver.SelectLocalBinding( + IPAddress.Parse("192.16.1.115"), "LAN", bindings); + Assert.Equal("192.16.1.115", chosen.LocalAddress.ToString()); + Assert.Equal("LAN", chosen.InterfaceId); + } + + [Fact] + public void MissingOrStaleIp_IsRejectedWithoutRebindingToDifferentNic() + { + var bindings = new[] { Binding("192.16.1.33", "LAN") }; + Assert.Throws(() => + SntpNetworkRouteResolver.SelectLocalBinding( + IPAddress.Parse("192.16.1.115"), "LAN", bindings)); + Assert.Throws(() => + SntpNetworkRouteResolver.SelectLocalBinding( + IPAddress.Parse("192.16.1.33"), "WiFi", bindings)); + } + + [Fact] + public void DuplicateIpAcrossAdapters_FailsClosedWithoutExplicitNic() + { + var bindings = new[] { Binding("192.16.1.33", "LAN"), Binding("192.16.1.33", "USB") }; + Assert.Throws(() => + SntpNetworkRouteResolver.SelectLocalBinding( + IPAddress.Parse("192.16.1.33"), null, bindings)); + Assert.Equal("USB", SntpNetworkRouteResolver.SelectLocalBinding( + IPAddress.Parse("192.16.1.33"), "USB", bindings).InterfaceId); + } + + [Theory] + [InlineData("127.0.0.1")] + [InlineData("0.0.0.0")] + public void UnsafeListenAddresses_AreRejected(string ip) + { + Assert.Throws(() => + SntpNetworkRouteResolver.SelectLocalBinding( + IPAddress.Parse(ip), null, new[] { Binding("192.16.1.33", "LAN") })); + } + + [Fact] + public void NoNetworkCandidates_ReturnsEmptyListWithoutThrowing() + { + var resolver = File.ReadAllText(Find("Services/SntpNetworkRouteResolver.cs")); + Assert.Contains("return candidates;", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("No active IPv4 station-bus network adapter is available.", resolver, StringComparison.Ordinal); + } + + private static string Find(string name) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var path = Path.Combine(directory.FullName, name); + if (File.Exists(path)) return path; + directory = directory.Parent; + } + throw new FileNotFoundException(name); + } +}