diff --git a/IoListTestingWindow.ClockSyncUx.cs b/IoListTestingWindow.ClockSyncUx.cs new file mode 100644 index 00000000..17486929 --- /dev/null +++ b/IoListTestingWindow.ClockSyncUx.cs @@ -0,0 +1,89 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private CheckBox? _clockSyncCheckBox; + private bool _clockSyncCheckBoxRefreshing; + + protected override void OnInitialized(EventArgs e) + { + base.OnInitialized(e); + Loaded += ClockSyncUx_Loaded; + } + + private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) + { + if (_clockSyncCheckBox != null) + { + RefreshClockSyncCheckBox(); + return; + } + + if (WorkspacePreviewToggle.Parent is not Panel actionPanel) + return; + + var previewIndex = actionPanel.Children.IndexOf(WorkspacePreviewToggle); + if (previewIndex < 0) + return; + + var checkBox = new CheckBox + { + Name = "ClockSyncEnabledCheckBox", + Content = "Clock Sync", + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 12, 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." + }; + + _clockSyncCheckBox = checkBox; + RefreshClockSyncCheckBox(); + checkBox.Checked += ClockSyncCheckBox_Changed; + checkBox.Unchecked += ClockSyncCheckBox_Changed; + actionPanel.Children.Insert(previewIndex + 1, checkBox); + } + + private void RefreshClockSyncCheckBox() + { + if (_clockSyncCheckBox == null || Owner is not MainWindow mainWindow) + return; + + _clockSyncCheckBoxRefreshing = true; + try + { + _clockSyncCheckBox.IsChecked = mainWindow.IsClockSyncEnabled; + } + finally + { + _clockSyncCheckBoxRefreshing = false; + } + } + + private async void ClockSyncCheckBox_Changed(object sender, RoutedEventArgs e) + { + if (_clockSyncCheckBoxRefreshing || + _clockSyncCheckBox == null || + Owner is not MainWindow mainWindow) + return; + + var requested = _clockSyncCheckBox.IsChecked == true; + _clockSyncCheckBox.IsEnabled = false; + try + { + await mainWindow.SetClockSyncEnabledAsync(requested); + RefreshClockSyncCheckBox(); + } + finally + { + _clockSyncCheckBox.IsEnabled = true; + } + } +} diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs new file mode 100644 index 00000000..b6a22226 --- /dev/null +++ b/MainWindow.ClockSyncToggle.cs @@ -0,0 +1,53 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private bool _clockSyncEnabled = true; + + internal bool IsClockSyncEnabled => _clockSyncEnabled; + + internal async Task SetClockSyncEnabledAsync(bool enabled) + { + if (_clockSyncEnabled == enabled) + return; + + _clockSyncEnabled = enabled; + + if (!enabled) + { + Devices.CollectionChanged -= ClockSyncDevices_CollectionChanged; + foreach (var device in Devices) + device.PropertyChanged -= ClockSyncDevice_PropertyChanged; + + await _clockSyncIntegrationGate.WaitAsync(); + try + { + await _sntpClockService.StopAsync(); + _clockSyncObservedClients.Clear(); + AddLog("INFO", "Clock Sync", + "Clock Sync disabled from the FAT workspace. IEC 61850 monitoring remains active."); + } + catch (Exception ex) + { + AddLog("WARN", "Clock Sync", + $"Clock Sync disable requested, but the SNTP service reported: {ex.Message}"); + } + finally + { + _clockSyncIntegrationGate.Release(); + } + + return; + } + + Devices.CollectionChanged -= ClockSyncDevices_CollectionChanged; + Devices.CollectionChanged += ClockSyncDevices_CollectionChanged; + foreach (var device in Devices) + 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."); + } +} diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs index 0a16cb8d..c81c9d51 100644 --- a/MainWindow.IoTesting.cs +++ b/MainWindow.IoTesting.cs @@ -387,7 +387,7 @@ private IoTestSessionController CreateIoTestSession(IoTestProject project, strin => new( project, ResolveIoTestDevice, - action => Dispatcher.BeginInvoke(action, DispatcherPriority.Background), + action => Dispatcher.BeginInvoke(action, DispatcherPriority.DataBind), evidenceRoot); private static string IoTestingProjectsRoot() => Path.Combine( diff --git a/tests/ARSAS.Tests/IoFatImmediateEvidenceRegressionTests.cs b/tests/ARSAS.Tests/IoFatImmediateEvidenceRegressionTests.cs new file mode 100644 index 00000000..f0a39b2a --- /dev/null +++ b/tests/ARSAS.Tests/IoFatImmediateEvidenceRegressionTests.cs @@ -0,0 +1,64 @@ +using System.ComponentModel; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatImmediateEvidenceRegressionTests +{ + [Fact] + public void TrueEdge_PublishesOnTimestampBeforeFalseEdgeArrives() + { + var evaluator = new IoTestTransitionEvaluator(); + var point = CreatePoint(); + var onTimestampNotificationRaised = false; + + point.Runtime.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(IoTestPointRuntime.OnRelayTimestampText)) + onTimestampNotificationRaised = true; + }; + + evaluator.StartAttempt(point, Observation(false, 1)); + var on = evaluator.Observe(point, Observation(true, 2)); + + Assert.Equal(IoTestPointState.OnCaptured, on.State); + Assert.NotNull(point.Runtime.OnEvidence); + Assert.Null(point.Runtime.OffEvidence); + Assert.Equal( + point.Runtime.OnEvidence!.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff"), + point.Runtime.OnRelayTimestampText); + Assert.NotEqual("—", point.Runtime.OnRelayTimestampText); + Assert.True(onTimestampNotificationRaised); + } + + private static IoTestPointPlan CreatePoint() + => new() + { + TestPointId = "TRUE-EVIDENCE-REGRESSION", + IedName = "SIPROTEC", + IpAddress = "192.168.1.10", + SignalName = "Protection pickup", + ObjectReference = "SIPROTEC/PROT.Op.general", + FunctionalConstraint = "ST", + ExpectedOnText = "true", + ExpectedOffText = "false", + ImportReady = true, + BindingStatus = "CID_DATASET_EXACT" + }; + + private static IoTestObservation Observation(bool state, long sequence) + { + var captured = new DateTimeOffset(2026, 8, 13, 4, 55, 0, TimeSpan.Zero) + .AddMilliseconds(sequence * 100); + return new IoTestObservation( + state, + state ? "true" : "false", + captured, + captured.AddMilliseconds(-4), + "Good", + "BRCB", + sequence, + 1); + } +}