From 9aa6f29faab80557d148f26077bd5a6521994343 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 08:50:03 +0700 Subject: [PATCH 1/9] feat(sntp): enumerate local PC bindings and start UDP server by selected NIC without MMS --- Services/SntpClockService.cs | 33 ++++++++++++++++++++++++++++ Services/SntpNetworkRouteResolver.cs | 33 +++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 3 deletions(-) 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..353a708fb 100644 --- a/Services/SntpNetworkRouteResolver.cs +++ b/Services/SntpNetworkRouteResolver.cs @@ -21,6 +21,36 @@ 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) + { + ArgumentNullException.ThrowIfNull(localAddress); + 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 = GetLocalBindings() + .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 +147,6 @@ private static List GetIpv4Candidates() } } - if (candidates.Count == 0) - throw new InvalidOperationException("No active IPv4 station-bus network adapter is available."); - return candidates; } From a4a3c46cf6d902e6ccc8e768c118e387c6dba732 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 08:51:16 +0700 Subject: [PATCH 2/9] refactor(sntp): isolate clock lifecycle from IED events and serialize selected local IP binding --- MainWindow.ClockSync.cs | 185 ++++++++++++++-------------------------- 1 file changed, 65 insertions(+), 120 deletions(-) diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs index af2c2a58d..d6fe71c80 100644 --- a/MainWindow.ClockSync.cs +++ b/MainWindow.ClockSync.cs @@ -1,7 +1,4 @@ -using System.Collections.Specialized; -using System.ComponentModel; using System.Net; -using ArIED61850Tester.Models; using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -14,6 +11,7 @@ public partial class MainWindow private readonly HashSet _clockSyncRepliedClients = new(StringComparer.OrdinalIgnoreCase); private string _lastClockSyncStatus = string.Empty; private bool _clockSyncLifecycleAttached; + private long _clockSyncDesiredVersion; internal event Action? ClockSyncSnapshotChanged; internal SntpClockServiceSnapshot ClockSyncSnapshot => _sntpClockService.Snapshot; @@ -22,109 +20,83 @@ 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(); + + 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}"); + AddLog("WARN", "SNTP Server", $"Cannot serve the selected PC IP: {ex.Message}"); + SetStatus("SNTP Server: check selected PC IP or UDP/123 availability."); } finally { _clockSyncIntegrationGate.Release(); + PublishGlobalSntpUiState(); } } @@ -132,93 +104,66 @@ private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot snapshot) { void Publish() { - // 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); + AddLog(snapshot.State is SntpClockServiceState.Faulted or SntpClockServiceState.PortUnavailable + ? "WARN" : "INFO", "SNTP Server", snapshot.Detail); } - - if (Dispatcher.CheckAccess()) - Publish(); - else + if (Dispatcher.CheckAccess()) Publish(); + else if (!Dispatcher.HasShutdownStarted) Dispatcher.BeginInvoke(new Action(Publish)); } private void ClockSyncService_ClientRequestObserved(SntpClientObservation observation) { var key = observation.Address.ToString(); - void Publish() { - if (!_clockSyncObservedClients.Add(key)) - return; - + 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(); - void Publish() { - if (!_clockSyncRepliedClients.Add(key)) - return; - + 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. } } } From 916618a600f2a6e7d5ccb0333cf9c74918a395cd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 08:52:34 +0700 Subject: [PATCH 3/9] ux(sntp): compact standalone toggle, PC IP dropdown and communication indicator --- MainWindow.ClockSyncToggle.cs | 341 +++++++++++++++------------------- 1 file changed, 147 insertions(+), 194 deletions(-) diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs index 39598e3b3..341d0a711 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; + _globalSntpToggle.IsEnabled = false; + try { await SetClockSyncEnabledAsync(_globalSntpToggle.IsChecked == true); } + finally { _globalSntpToggle.IsEnabled = 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"}"; } } From 85db8f868d807e9ba214edaaeda02376f1ab0dfd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 08:53:31 +0700 Subject: [PATCH 4/9] test(sntp): lock independent PC IP lifecycle, concise toolbar and transport invariants --- docs/SNTP_CLOCK_SYNC.md | 99 ++++--------------- .../GlobalSntpLifecycleRegressionTests.cs | 95 +++++++++++------- 2 files changed, 74 insertions(+), 120 deletions(-) diff --git a/docs/SNTP_CLOCK_SYNC.md b/docs/SNTP_CLOCK_SYNC.md index 530418f88..3d782f750 100644 --- a/docs/SNTP_CLOCK_SYNC.md +++ b/docs/SNTP_CLOCK_SYNC.md @@ -1,91 +1,26 @@ -# ARSAS Clock Sync — SNTP commissioning service +# Standalone ARSAS SNTP Server -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. +ARSAS serves **a selected local PC IPv4 address** on UDP/123 independently from IEC 61850 association, IED Discovery, Open SCL, GOOSE or FAT. This is a managed .NET lifecycle around the existing tested SNTP protocol and transport implementations. -## Current behavior +## Operator workflow -- 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. -- 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. -- 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). -- Never fails an IEC 61850 association when SNTP cannot start. +1. In the main toolbar select a local computer IPv4 address in the dropdown. All active non-loopback unicast IPv4 addresses, including aliases on one NIC, are enumerated when the dropdown opens. +2. Switch **NTP Server** ON. ARSAS validates the selected address and adapter and starts its SNTP transport without opening any IED connection. +3. Switch OFF to stop all transport workers and release UDP/123. Changing the PC IP while enabled serializes Stop, revalidation and Start. +4. Communication dot: grey=off; blue=serving/waiting; amber=requests unanswered or service unavailable; green=at least one Mode-4 reply sent this run; red=fault. This reflects SNTP packet exchange, **not proof of relay clock synchronization**. Hover for details. -## Evidence semantics +The service defaults **OFF** until the operator enables it. It stops when ARSAS closes. If an active PC IP disappears, refresh the dropdown to stop serving the stale interface. A disconnected or ambiguous binding cannot be silently moved to an unrelated NIC. -FAT Clock Sync telemetry intentionally distinguishes packet activity from actual relay synchronization: +## Transport and time trust -- `B` / Broadcast: ARSAS transmitted a Mode 5 broadcast. -- `Req`: ARSAS observed a client Mode 3 request from an IED. -- `Reply`: ARSAS successfully transmitted a Mode 4 response. -- `sync not proven`: none of the three counters alone proves that the relay accepted the source or adjusted its internal clock. +The existing SNTPv3/v4 Mode-4 unicast and Mode-5 directed-broadcast implementations remain intact. On an explicit ON command, ARSAS tries to bind UDP/123 exclusively to the selected PC IP. If another service owns that socket, the existing Npcap RAW path may be used; Windows Time is not disabled or reconfigured. No new unmanaged implementation is introduced. -A broadcast without a client request may still be valid when the relay is explicitly configured for broadcast NTP, but ARSAS does not treat it as an acknowledgement. For unicast SNTP, the strongest wire-level evidence is a Mode 3 request followed by a Mode 4 reply. Device-side time-quality or clock evidence is still required before declaring the relay synchronized. +The service is a **local Windows-clock commissioning reference**, not a GPS receiver, UTC-traceable grandmaster or guaranteed time source for protection tests. Existing clock-health guards, compatibility stratum and device-side evidence requirements remain unchanged. -## Commissioning compatibility stratum +## Acceptance -Authorized field observations on the historical reference configuration showed that a conservative high-stratum local source could be rejected or remain marked unsynchronized. ARSAS therefore uses the same `stratum 2` advertisement for Mode 4 replies and Mode 5 broadcasts. This is a bounded commissioning default, not a claim about all IEDs. The original device-specific naming and observation context remain traceable in the [pre-migration source revision](https://github.com/masarray/arsas/commit/e03ff1caa7d83902ef106f0c4aafdc3fb24143e5). - -The value is named in code as `SntpServerProfile.CommissioningCompatibilityStratum` and is protected by regression tests. It does not claim that the Windows laptop is physically traceable to a stratum-1 GNSS/PTP/atomic source. `LOCL` remains the reference ID and ARSAS diagnostics describe the laptop as a local commissioning source. - -If the Windows clock fails the ARSAS clock-health guard, synchronized stratum is not advertised: the affected unicast response becomes unsynchronized (`LI=3`, `stratum=0`, `INIT`) and broadcast is suppressed. - -## Accuracy and trust boundary - -ARSAS intentionally does not claim UTC traceability. The Windows system clock is treated as a temporary commissioning reference. ARSAS checks for gross time sanity and sudden wall-clock steps, but it does not claim the laptop is equivalent to GPS, IRIG-B, PTP, or an IEC/IEEE 61850-9-3 grandmaster. - -A later phase can add explicit Windows upstream-source verification and/or device-side time-quality correlation without changing the SNTP packet engine. - -## UDP/123 ownership and Npcap RAW fallback - -Windows Time and other NTP software may already own UDP/123. ARSAS first attempts exclusive ownership of UDP/123 on the selected station-bus address. If that succeeds, normal Windows UDP sockets are used. - -If the bind fails, ARSAS leaves the existing Windows service untouched and attempts an Npcap RAW fallback on the same adapter. The fallback: - -- captures Ethernet frames matching `udp dst port 123`; -- accepts only supported IPv4 SNTP Mode 3 client requests addressed to the station-bus laptop; -- preserves a single incoming 802.1Q/802.1ad VLAN tag on the Mode 4 reply; -- builds Ethernet, IPv4 and UDP headers directly; -- calculates IPv4 and UDP checksums; -- replies directly to the request source MAC/IP/UDP port; -- injects Mode 5 broadcasts with Ethernet broadcast MAC and the route-derived directed-broadcast IPv4 address. - -If both the normal socket path and Npcap fallback are unavailable, Clock Sync reports `PortUnavailable`; MMS/GOOSE/SV/FAT IEC 61850 behavior remains fail-open. - -Npcap is therefore optional for the normal UDP path but required for the RAW fallback. The Windows installer warns when Npcap is not detected; it does not silently install drivers or modify Windows Time/firewall policy. - -## 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. - -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. - -## Validation - -`SntpPacketTests` covers: - -- SNTP client request recognition; -- version/poll field copy behavior; -- Mode 4 reply semantics; -- originate timestamp echo; -- commissioning compatibility stratum 2 on unicast and broadcast packets; -- Mode 5 broadcast semantics; -- RFC-style unsynchronized response fields; -- directed-broadcast calculation; -- NTP timestamp round-trip accuracy. - -`SntpEthernetFrameCodecTests` additionally covers: - -- raw Ethernet Mode 3 recognition; -- MAC/IP/UDP endpoint swapping for Mode 4 replies; -- valid IPv4 and UDP checksums; -- preservation of a single VLAN tag; -- raw Mode 5 Ethernet/directed-broadcast construction; -- rejection of non-Mode-3 or wrong-destination-port traffic. +- Start from zero IEDs, choose PC IP, switch ON: serving UDP/123 without waiting for MMS. +- Valid SNTP request to that PC IP gets the expected Mode-4 response; indicator responds to observed requests/replies. +- Switch OFF: service releases transport. Toggle ON/OFF repeatedly without duplicate workers or listeners. +- Change selected PC IP when serving: stop and start once with updated adapter; reject missing/ambiguous addresses. +- Leave IED connections, reports, control functions, GOOSE monitoring and FAT operationally unaffected. 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); } } From 3ed6c5eced0ceafe2092f8664fd0a1ba8ca53b4a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 08:55:54 +0700 Subject: [PATCH 5/9] test(sntp): pure canonical local NIC selection and fail-closed rebind behavior --- MainWindow.ClockSync.cs | 10 ++- Services/SntpNetworkRouteResolver.cs | 11 ++- tests/ARSAS.Tests/SntpLocalBindingTests.cs | 80 ++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 tests/ARSAS.Tests/SntpLocalBindingTests.cs diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs index d6fe71c80..cfc09fa02 100644 --- a/MainWindow.ClockSync.cs +++ b/MainWindow.ClockSync.cs @@ -90,8 +90,16 @@ await _sntpClockService.StartOnLocalAddressAsync( } catch (Exception ex) { + // Fail closed on stale/missing IP: do not keep advertising time on + // the previous NIC after the user has explicitly changed binding. + _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 selected PC IP or UDP/123 availability."); + SetStatus("SNTP Server: check the selected PC IP."); } finally { diff --git a/Services/SntpNetworkRouteResolver.cs b/Services/SntpNetworkRouteResolver.cs index 353a708fb..cea95840d 100644 --- a/Services/SntpNetworkRouteResolver.cs +++ b/Services/SntpNetworkRouteResolver.cs @@ -37,12 +37,21 @@ public static IReadOnlyList GetLocalBindings() /// 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 = GetLocalBindings() + var matches = available .Where(item => item.LocalAddress.Equals(localAddress) && (interfaceId is null || item.InterfaceId.Equals(interfaceId, StringComparison.OrdinalIgnoreCase))) .ToArray(); 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); + } +} From 1aa863219d32f1a2cbc7d7133f89c71df280d769 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 08:58:13 +0700 Subject: [PATCH 6/9] perf(sntp): coalesce UI status bursts and deduplicate client activity before dispatch --- MainWindow.ClockSync.cs | 57 +++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs index cfc09fa02..dbf42cf8e 100644 --- a/MainWindow.ClockSync.cs +++ b/MainWindow.ClockSync.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.Net; +using System.Windows.Threading; using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -7,8 +9,9 @@ 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; @@ -77,7 +80,11 @@ private async Task ReconcileStandaloneClockAsync() return; if (active.State != SntpClockServiceState.Stopped) + { await _sntpClockService.StopAsync(); + _clockSyncObservedClients.Clear(); + _clockSyncRepliedClients.Clear(); + } if (version != Volatile.Read(ref _clockSyncDesiredVersion) || !_clockSyncEnabled) return; @@ -108,30 +115,46 @@ await _sntpClockService.StartOnLocalAddressAsync( } } - 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()) { - 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); + RenderClockSyncStatus(_sntpClockService.Snapshot); + return; } - if (Dispatcher.CheckAccess()) Publish(); - else if (!Dispatcher.HasShutdownStarted) - Dispatcher.BeginInvoke(new Action(Publish)); + 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); + })); + } + + 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)); AddLog("INFO", "SNTP Server", @@ -144,9 +167,9 @@ void 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)); AddLog("INFO", "SNTP Server", From 2d7f9b8debbb4c8c1fd5025b7cc06c7b86668945 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 09:01:07 +0700 Subject: [PATCH 7/9] fix(sntp): preserve FAT diagnostic request/reply lookup on concurrent cache --- MainWindow.NativeFatDiagnostics.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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."); From b0d7848ce9ec8d6c517c9492e987188ed405a54f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 09:02:16 +0700 Subject: [PATCH 8/9] fix(sntp): keep toggle responsive and reject stale startup failures after newer intent --- MainWindow.ClockSync.cs | 19 +++++++++++-------- MainWindow.ClockSyncToggle.cs | 6 +++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs index dbf42cf8e..b517752ab 100644 --- a/MainWindow.ClockSync.cs +++ b/MainWindow.ClockSync.cs @@ -97,16 +97,19 @@ await _sntpClockService.StartOnLocalAddressAsync( } catch (Exception ex) { - // Fail closed on stale/missing IP: do not keep advertising time on - // the previous NIC after the user has explicitly changed binding. - _clockSyncEnabled = false; - try { await _sntpClockService.StopAsync(); } - catch (Exception stopError) + // 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)) { - AddLog("WARN", "SNTP Server", $"Stop after failed bind: {stopError.Message}"); + _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."); } - AddLog("WARN", "SNTP Server", $"Cannot serve the selected PC IP: {ex.Message}"); - SetStatus("SNTP Server: check the selected PC IP."); } finally { diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs index 341d0a711..338a41b93 100644 --- a/MainWindow.ClockSyncToggle.cs +++ b/MainWindow.ClockSyncToggle.cs @@ -150,9 +150,9 @@ private async void GlobalSntpIp_SelectionChanged(object sender, SelectionChanged private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e) { if (_globalSntpUiRefreshing || _globalSntpToggle is null) return; - _globalSntpToggle.IsEnabled = false; - try { await SetClockSyncEnabledAsync(_globalSntpToggle.IsChecked == true); } - finally { _globalSntpToggle.IsEnabled = true; } + // 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); } internal async Task SetClockSyncEnabledAsync(bool enabled) From 5e46dd562fc1f19f34eddc493b8c9872cb37bae6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 09:06:37 +0700 Subject: [PATCH 9/9] docs(sntp): preserve commissioning protocol history while documenting standalone IP workflow --- docs/SNTP_CLOCK_SYNC.md | 110 +++++++++++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 17 deletions(-) diff --git a/docs/SNTP_CLOCK_SYNC.md b/docs/SNTP_CLOCK_SYNC.md index 3d782f750..14122c40a 100644 --- a/docs/SNTP_CLOCK_SYNC.md +++ b/docs/SNTP_CLOCK_SYNC.md @@ -1,26 +1,102 @@ -# Standalone ARSAS SNTP Server +# ARSAS Clock Sync — SNTP commissioning service -ARSAS serves **a selected local PC IPv4 address** on UDP/123 independently from IEC 61850 association, IED Discovery, Open SCL, GOOSE or FAT. This is a managed .NET lifecycle around the existing tested SNTP protocol and transport implementations. +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. -## Operator workflow +## Minimal operator UI -1. In the main toolbar select a local computer IPv4 address in the dropdown. All active non-loopback unicast IPv4 addresses, including aliases on one NIC, are enumerated when the dropdown opens. -2. Switch **NTP Server** ON. ARSAS validates the selected address and adapter and starts its SNTP transport without opening any IED connection. -3. Switch OFF to stop all transport workers and release UDP/123. Changing the PC IP while enabled serializes Stop, revalidation and Start. -4. Communication dot: grey=off; blue=serving/waiting; amber=requests unanswered or service unavailable; green=at least one Mode-4 reply sent this run; red=fault. This reflects SNTP packet exchange, **not proof of relay clock synchronization**. Hover for details. +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 service defaults **OFF** until the operator enables it. It stops when ARSAS closes. If an active PC IP disappears, refresh the dropdown to stop serving the stale interface. A disconnected or ambiguous binding cannot be silently moved to an unrelated NIC. +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. -## Transport and time trust +## Current behavior -The existing SNTPv3/v4 Mode-4 unicast and Mode-5 directed-broadcast implementations remain intact. On an explicit ON command, ARSAS tries to bind UDP/123 exclusively to the selected PC IP. If another service owns that socket, the existing Npcap RAW path may be used; Windows Time is not disabled or reconfigured. No new unmanaged implementation is introduced. +- 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. +- 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). +- Never fails an IEC 61850 association when SNTP cannot start. -The service is a **local Windows-clock commissioning reference**, not a GPS receiver, UTC-traceable grandmaster or guaranteed time source for protection tests. Existing clock-health guards, compatibility stratum and device-side evidence requirements remain unchanged. +## Evidence semantics -## Acceptance +FAT Clock Sync telemetry intentionally distinguishes packet activity from actual relay synchronization: -- Start from zero IEDs, choose PC IP, switch ON: serving UDP/123 without waiting for MMS. -- Valid SNTP request to that PC IP gets the expected Mode-4 response; indicator responds to observed requests/replies. -- Switch OFF: service releases transport. Toggle ON/OFF repeatedly without duplicate workers or listeners. -- Change selected PC IP when serving: stop and start once with updated adapter; reject missing/ambiguous addresses. -- Leave IED connections, reports, control functions, GOOSE monitoring and FAT operationally unaffected. +- `B` / Broadcast: ARSAS transmitted a Mode 5 broadcast. +- `Req`: ARSAS observed a client Mode 3 request from an IED. +- `Reply`: ARSAS successfully transmitted a Mode 4 response. +- `sync not proven`: none of the three counters alone proves that the relay accepted the source or adjusted its internal clock. + +A broadcast without a client request may still be valid when the relay is explicitly configured for broadcast NTP, but ARSAS does not treat it as an acknowledgement. For unicast SNTP, the strongest wire-level evidence is a Mode 3 request followed by a Mode 4 reply. Device-side time-quality or clock evidence is still required before declaring the relay synchronized. + +## Commissioning compatibility stratum + +Authorized field observations on the historical reference configuration showed that a conservative high-stratum local source could be rejected or remain marked unsynchronized. ARSAS therefore uses the same `stratum 2` advertisement for Mode 4 replies and Mode 5 broadcasts. This is a bounded commissioning default, not a claim about all IEDs. The original device-specific naming and observation context remain traceable in the [pre-migration source revision](https://github.com/masarray/arsas/commit/e03ff1caa7d83902ef106f0c4aafdc3fb24143e5). + +The value is named in code as `SntpServerProfile.CommissioningCompatibilityStratum` and is protected by regression tests. It does not claim that the Windows laptop is physically traceable to a stratum-1 GNSS/PTP/atomic source. `LOCL` remains the reference ID and ARSAS diagnostics describe the laptop as a local commissioning source. + +If the Windows clock fails the ARSAS clock-health guard, synchronized stratum is not advertised: the affected unicast response becomes unsynchronized (`LI=3`, `stratum=0`, `INIT`) and broadcast is suppressed. + +## Accuracy and trust boundary + +ARSAS intentionally does not claim UTC traceability. The Windows system clock is treated as a temporary commissioning reference. ARSAS checks for gross time sanity and sudden wall-clock steps, but it does not claim the laptop is equivalent to GPS, IRIG-B, PTP, or an IEC/IEEE 61850-9-3 grandmaster. + +A later phase can add explicit Windows upstream-source verification and/or device-side time-quality correlation without changing the SNTP packet engine. + +## UDP/123 ownership and Npcap RAW fallback + +Windows Time and other NTP software may already own UDP/123. ARSAS first attempts exclusive ownership of UDP/123 on the selected station-bus address. If that succeeds, normal Windows UDP sockets are used. + +If the bind fails, ARSAS leaves the existing Windows service untouched and attempts an Npcap RAW fallback on the same adapter. The fallback: + +- captures Ethernet frames matching `udp dst port 123`; +- accepts only supported IPv4 SNTP Mode 3 client requests addressed to the station-bus laptop; +- preserves a single incoming 802.1Q/802.1ad VLAN tag on the Mode 4 reply; +- builds Ethernet, IPv4 and UDP headers directly; +- calculates IPv4 and UDP checksums; +- replies directly to the request source MAC/IP/UDP port; +- injects Mode 5 broadcasts with Ethernet broadcast MAC and the route-derived directed-broadcast IPv4 address. + +If both the normal socket path and Npcap fallback are unavailable, Clock Sync reports `PortUnavailable`; MMS/GOOSE/SV/FAT IEC 61850 behavior remains fail-open. + +Npcap is therefore optional for the normal UDP path but required for the RAW fallback. The Windows installer warns when Npcap is not detected; it does not silently install drivers or modify Windows Time/firewall policy. + +## Network scope + +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. + +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 + +`SntpPacketTests` covers: + +- SNTP client request recognition; +- version/poll field copy behavior; +- Mode 4 reply semantics; +- originate timestamp echo; +- commissioning compatibility stratum 2 on unicast and broadcast packets; +- Mode 5 broadcast semantics; +- RFC-style unsynchronized response fields; +- directed-broadcast calculation; +- NTP timestamp round-trip accuracy. + +`SntpEthernetFrameCodecTests` additionally covers: + +- raw Ethernet Mode 3 recognition; +- MAC/IP/UDP endpoint swapping for Mode 4 replies; +- valid IPv4 and UDP checksums; +- 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