Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 109 additions & 130 deletions MainWindow.ClockSync.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,10 +9,12 @@ public partial class MainWindow
{
private readonly SntpClockService _sntpClockService = new();
private readonly SemaphoreSlim _clockSyncIntegrationGate = new(1, 1);
private readonly HashSet<string> _clockSyncObservedClients = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _clockSyncRepliedClients = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _clockSyncObservedClients = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _clockSyncRepliedClients = new(StringComparer.OrdinalIgnoreCase);
private int _clockSyncUiRenderQueued;
private string _lastClockSyncStatus = string.Empty;
private bool _clockSyncLifecycleAttached;
private long _clockSyncDesiredVersion;

internal event Action<SntpClockServiceSnapshot>? ClockSyncSnapshotChanged;
internal SntpClockServiceSnapshot ClockSyncSnapshot => _sntpClockService.Snapshot;
Expand All @@ -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<Iec61850MonitorDevice>())
item.PropertyChanged -= ClockSyncDevice_PropertyChanged;
await _clockSyncIntegrationGate.WaitAsync(_applicationCancellation.Token);
}

if (e.NewItems != null)
catch (OperationCanceledException)
{
foreach (var item in e.NewItems.OfType<Iec61850MonitorDevice>())
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.
}
}
}
Loading
Loading