Skip to content
Merged
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
108 changes: 106 additions & 2 deletions IoListTestingWindow.ClockSyncUx.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using ArIED61850Tester.Services;

namespace ArIED61850Tester;

public partial class IoListTestingWindow
{
private CheckBox? _clockSyncCheckBox;
private TextBlock? _clockSyncEvidenceText;
private MainWindow? _clockSyncSnapshotOwner;
private bool _clockSyncCheckBoxRefreshing;

protected override void OnInitialized(EventArgs e)
{
base.OnInitialized(e);
Loaded += ClockSyncUx_Loaded;
Closed += ClockSyncUx_Closed;
}

private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e)
{
if (_clockSyncCheckBox != null)
{
RefreshClockSyncCheckBox();
AttachClockSyncSnapshotOwner();
return;
}

Expand All @@ -35,20 +40,111 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e)
Name = "ClockSyncEnabledCheckBox",
Content = "Clock Sync",
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 12, 0),
Margin = new Thickness(0, 0, 8, 0),
Padding = new Thickness(2, 0, 2, 0),
FontSize = 11.2,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("Ink") as Brush ?? Brushes.DimGray,
Focusable = false,
ToolTip = "SNTP laptop → IED. Checked: ARSAS serves and broadcasts laptop time using the SIPROTEC compatibility profile (stratum 2). Unchecked: ARSAS stops its SNTP service. IEC 61850 monitoring is unaffected."
ToolTip = "SNTP laptop → IED. Checked: ARSAS serves laptop time using normal UDP/123 when available, with an Npcap RAW fallback when Windows already owns UDP/123. Unchecked: ARSAS stops only its Clock Sync service. IEC 61850 remains unaffected."
};

var evidence = new TextBlock
{
Name = "ClockSyncEvidenceTextBlock",
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 12, 0),
FontSize = 10.4,
FontWeight = FontWeights.Medium,
Foreground = TryFindResource("MutedInk") as Brush ?? Brushes.SlateGray,
Text = "Clock: waiting"
};

_clockSyncCheckBox = checkBox;
_clockSyncEvidenceText = evidence;
RefreshClockSyncCheckBox();
checkBox.Checked += ClockSyncCheckBox_Changed;
checkBox.Unchecked += ClockSyncCheckBox_Changed;
actionPanel.Children.Insert(previewIndex + 1, checkBox);
actionPanel.Children.Insert(previewIndex + 2, evidence);
AttachClockSyncSnapshotOwner();
}

private void AttachClockSyncSnapshotOwner()
{
if (Owner is not MainWindow mainWindow)
return;

if (!ReferenceEquals(_clockSyncSnapshotOwner, mainWindow))
{
if (_clockSyncSnapshotOwner != null)
_clockSyncSnapshotOwner.ClockSyncSnapshotChanged -= ClockSyncSnapshotChanged;

_clockSyncSnapshotOwner = mainWindow;
mainWindow.ClockSyncSnapshotChanged += ClockSyncSnapshotChanged;
}

RefreshClockSyncEvidence(mainWindow.ClockSyncSnapshot);
}

private void ClockSyncSnapshotChanged(SntpClockServiceSnapshot snapshot)
{
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(new Action(() => RefreshClockSyncEvidence(snapshot)));
return;
}

RefreshClockSyncEvidence(snapshot);
}

private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot)
{
if (_clockSyncEvidenceText == null)
return;

var transport = snapshot.TransportMode switch
{
SntpClockTransportMode.NpcapRaw => "RAW",
SntpClockTransportMode.UdpSocket => "UDP",
_ => "—"
};

_clockSyncEvidenceText.Text = snapshot.State switch
{
SntpClockServiceState.Serving =>
$"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven",
SntpClockServiceState.Starting => "Clock: starting…",
SntpClockServiceState.Stopped => "Clock: off",
SntpClockServiceState.PortUnavailable => "Clock: unavailable",
SntpClockServiceState.Faulted => "Clock: fault",
_ => $"Clock: {snapshot.State}"
};

_clockSyncEvidenceText.ToolTip = BuildClockSyncEvidenceToolTip(snapshot, transport);
_clockSyncEvidenceText.Foreground = snapshot.State switch
{
SntpClockServiceState.PortUnavailable or SntpClockServiceState.Faulted => Brushes.DarkOrange,
SntpClockServiceState.Serving when snapshot.ReplyCount > 0 => Brushes.SeaGreen,
_ => TryFindResource("MutedInk") as Brush ?? Brushes.SlateGray
};
}

private static string BuildClockSyncEvidenceToolTip(SntpClockServiceSnapshot snapshot, string transport)
{
var binding = snapshot.Binding == null ? "—" : snapshot.Binding.Summary;
var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—";
var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—";
var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—";
return $"Clock Sync evidence\n" +
$"State: {snapshot.State}\n" +
$"Transport: {transport}\n" +
$"Binding: {binding}\n" +
$"Broadcast sent: {snapshot.BroadcastCount} (last {lastBroadcast})\n" +
$"Client request seen: {snapshot.ClientRequestCount} (last {lastRequest})\n" +
$"Mode 4 reply sent: {snapshot.ReplyCount} (last {lastReply})\n\n" +
"These counters prove packet activity only. ARSAS does not claim that the relay clock is synchronized without device-side evidence.\n\n" +
snapshot.Detail;
}

private void RefreshClockSyncCheckBox()
Expand Down Expand Up @@ -80,10 +176,18 @@ private async void ClockSyncCheckBox_Changed(object sender, RoutedEventArgs e)
{
await mainWindow.SetClockSyncEnabledAsync(requested);
RefreshClockSyncCheckBox();
RefreshClockSyncEvidence(mainWindow.ClockSyncSnapshot);
}
finally
{
_clockSyncCheckBox.IsEnabled = true;
}
}

private void ClockSyncUx_Closed(object? sender, EventArgs e)
{
if (_clockSyncSnapshotOwner != null)
_clockSyncSnapshotOwner.ClockSyncSnapshotChanged -= ClockSyncSnapshotChanged;
_clockSyncSnapshotOwner = null;
}
}
39 changes: 35 additions & 4 deletions MainWindow.ClockSync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ 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 string _lastClockSyncStatus = string.Empty;
private bool _clockSyncLifecycleAttached;

internal event Action<SntpClockServiceSnapshot>? ClockSyncSnapshotChanged;
internal SntpClockServiceSnapshot ClockSyncSnapshot => _sntpClockService.Snapshot;

private void InitializeClockSyncLifecycle()
{
if (_clockSyncLifecycleAttached)
Expand All @@ -26,6 +30,7 @@ private void InitializeClockSyncLifecycle()

_sntpClockService.StatusChanged += ClockSyncService_StatusChanged;
_sntpClockService.ClientRequestObserved += ClockSyncService_ClientRequestObserved;
_sntpClockService.ReplySent += ClockSyncService_ReplySent;
Closed += ClockSyncMainWindow_Closed;
}

Expand Down Expand Up @@ -104,7 +109,11 @@ private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot snapshot)
{
void Publish()
{
var status = $"{snapshot.State}|{snapshot.Detail}";
// FAT telemetry must receive every evidence-counter change even when the textual
// service detail did not change. The live log remains deduplicated separately.
ClockSyncSnapshotChanged?.Invoke(snapshot);

var status = $"{snapshot.State}|{snapshot.TransportMode}|{snapshot.Detail}";
if (status.Equals(_lastClockSyncStatus, StringComparison.Ordinal))
return;

Expand All @@ -131,16 +140,37 @@ private void ClockSyncService_ClientRequestObserved(SntpClientObservation observ

void Publish()
{
// A request from the same client can occur indefinitely. Keep the live log quiet:
// first observation proves the client is using ARSAS; counters remain in the service snapshot.
if (!_clockSyncObservedClients.Add(key))
return;

var device = Devices.FirstOrDefault(item =>
item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase));
var name = device?.Name ?? key;
AddLog("INFO", "Clock Sync",
$"{name} ({key}) requested SNTPv{observation.Version}; ARSAS returned a Mode 4 reply from the station-bus interface.");
$"{name} ({key}) sent an SNTPv{observation.Version} client request to ARSAS. Request observed; synchronization is not yet proven.");
}

if (Dispatcher.CheckAccess())
Publish();
else
Dispatcher.BeginInvoke(new Action(Publish));
}

private void ClockSyncService_ReplySent(SntpReplyObservation observation)
{
var key = observation.Address.ToString();

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", "Clock Sync",
$"{name} ({key}) received an ARSAS SNTP Mode 4 reply via {transport}. Reply sent; relay clock synchronization remains unproven until device evidence confirms it.");
}

if (Dispatcher.CheckAccess())
Expand All @@ -159,6 +189,7 @@ private async void ClockSyncMainWindow_Closed(object? sender, EventArgs e)

_sntpClockService.StatusChanged -= ClockSyncService_StatusChanged;
_sntpClockService.ClientRequestObserved -= ClockSyncService_ClientRequestObserved;
_sntpClockService.ReplySent -= ClockSyncService_ReplySent;
await _sntpClockService.DisposeAsync();
}
catch
Expand Down
3 changes: 2 additions & 1 deletion MainWindow.ClockSyncToggle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal async Task SetClockSyncEnabledAsync(bool enabled)
{
await _sntpClockService.StopAsync();
_clockSyncObservedClients.Clear();
_clockSyncRepliedClients.Clear();
AddLog("INFO", "Clock Sync",
"Clock Sync disabled from the FAT workspace. IEC 61850 monitoring remains active.");
}
Expand All @@ -48,6 +49,6 @@ internal async Task SetClockSyncEnabledAsync(bool enabled)
AttachClockSyncDevice(device);

AddLog("INFO", "Clock Sync",
"Clock Sync enabled from the FAT workspace. ARSAS will advertise laptop time to connected IPv4 IEDs using the SIPROTEC-compatible SNTP profile.");
"Clock Sync enabled from the FAT workspace. ARSAS will serve connected IPv4 IEDs using normal UDP/123 when available and an Npcap RAW fallback when Windows already owns that port. Windows Time is never stopped or reconfigured.");
}
}
Loading
Loading