From fda92c6b1869c29b0382e25f1dbbe2bab7c68140 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 14:03:29 +0700 Subject: [PATCH] fix(goose,sntp): de-noise GOOSE events, pair exact FCDA quality, improve inspector and persist SNTP settings --- MainWindow.ClockSyncToggle.cs | 21 ++++- MainWindow.GooseTimeline.cs | 2 +- Services/GooseTimelineEventPolicy.cs | 33 +++++++- Services/GooseTypedValueInterpreter.cs | 80 +++++++++++++------ Services/SntpUserSettingsStore.cs | 52 ++++++++++++ Views/GooseSubscriberLiteView.xaml | 22 +++-- .../GooseCanonicalEngineeringTests.cs | 20 +++++ .../GooseTimelineEventPolicyTests.cs | 22 +++++ .../ARSAS.Tests/SntpUserSettingsStoreTests.cs | 57 +++++++++++++ 9 files changed, 277 insertions(+), 32 deletions(-) create mode 100644 Services/SntpUserSettingsStore.cs create mode 100644 tests/ARSAS.Tests/SntpUserSettingsStoreTests.cs diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs index 0cf330340..64afeee32 100644 --- a/MainWindow.ClockSyncToggle.cs +++ b/MainWindow.ClockSyncToggle.cs @@ -24,6 +24,7 @@ public partial class MainWindow private DateTimeOffset _globalSntpSessionStartedUtc = DateTimeOffset.MaxValue; private DateTimeOffset _globalSntpLastActivityPulseUtc = DateTimeOffset.MinValue; private SntpNetworkBinding? _selectedSntpBinding; + private readonly SntpSavedSettings? _savedSntpSettings = SntpUserSettingsStore.Load(); internal bool IsClockSyncEnabled => _clockSyncEnabled; @@ -177,6 +178,9 @@ private void InstallGlobalSntpToggle() _globalSntpPulseRing = ring; RefreshGlobalSntpPcAddresses(); RefreshGlobalSntpToggle(_sntpClockService.Snapshot); + // Restore a previously explicit ON only on the exact same live NIC/IP. + if (_savedSntpSettings?.Enabled == true && _selectedSntpBinding is not null) + _ = SetClockSyncEnabledAsync(true); } private void RefreshGlobalSntpPcAddresses() @@ -197,7 +201,9 @@ previous is not null && 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); + var restored = previous is null ? SntpUserSettingsStore.Match(choices, _savedSntpSettings) : null; + var selected = matched ?? restored ?? + (previous is null && _savedSntpSettings is null && !_clockSyncEnabled ? choices.FirstOrDefault() : null); _globalSntpUiRefreshing = true; try { @@ -222,6 +228,7 @@ private async void GlobalSntpIp_SelectionChanged(object sender, SelectionChanged } else PublishGlobalSntpUiState(); + SaveSntpPreference(); } private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e) @@ -238,6 +245,18 @@ internal async Task SetClockSyncEnabledAsync(bool enabled) BeginNtpVisualSession(); _clockSyncEnabled = enabled && _selectedSntpBinding is not null; await ReconcileStandaloneClockAsync(); + SaveSntpPreference(); + } + + private void SaveSntpPreference() + { + // No disk IO per packet. Shutdown stopping SNTP must not change saved state. + if (_selectedSntpBinding is not { } binding) return; + var saved = new SntpSavedSettings( + binding.InterfaceId, binding.LocalAddress.ToString(), + _clockSyncEnabled && _sntpClockService.Snapshot.State == SntpClockServiceState.Serving); + if (!SntpUserSettingsStore.Save(saved)) + AddLog("WARN", "SNTP Server", "Could not save the local SNTP preference."); } private void BeginNtpVisualSession() diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 9cde47ad7..0acf1280a 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -204,7 +204,7 @@ private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot frame) var fingerprint = new GooseTimelineSignature( string.Concat(values.Select(x => $"{x?.Length ?? 0}:{x}")), frame.Frame.Pdu.StateNumber.ToString(CultureInfo.InvariantCulture), - string.Join(" • ", frame.StreamEvent.Diagnostics.OrderBy(x => x, StringComparer.Ordinal)), + GooseTimelineEventPolicy.DiagnosticCategory(frame.StreamEvent.Diagnostics), frame.StreamEvent.GooseSequenceStatus.ToString()); GooseTimelineDecision decision; lock (_gooseTimelineGate) diff --git a/Services/GooseTimelineEventPolicy.cs b/Services/GooseTimelineEventPolicy.cs index 3313057ae..a6c7f2be6 100644 --- a/Services/GooseTimelineEventPolicy.cs +++ b/Services/GooseTimelineEventPolicy.cs @@ -6,6 +6,36 @@ public sealed record GooseTimelineSignature(string Payload, string State, string /// Presentation-only filter. All packets still enter the read-only wire capture. public static class GooseTimelineEventPolicy { + // Coalesce per-frame quantitative diagnostics into stable semantic + // categories FOR UI ONLY. Raw wire evidence is preserved in the inspector. + public static string DiagnosticCategory(IEnumerable diagnostics) + { + var categories = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in diagnostics) + { + if (string.IsNullOrWhiteSpace(item)) continue; + if (item.Contains("test flag", StringComparison.OrdinalIgnoreCase)) + categories.Add("test-flag"); + else if (item.Contains("supervision expired", StringComparison.OrdinalIgnoreCase)) + categories.Add("supervision-expired"); + else if (item.Contains("sequence gap", StringComparison.OrdinalIgnoreCase)) + categories.Add("sequence-gap"); + else + categories.Add(item.Trim()); + } + return string.Join("|", categories.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)); + } + + private static string SequenceFamily(string value) + { + if (value.Contains("retransmission", StringComparison.OrdinalIgnoreCase) || + value.Contains("normal", StringComparison.OrdinalIgnoreCase) || + value.Contains("new", StringComparison.OrdinalIgnoreCase) || + value.Contains("first", StringComparison.OrdinalIgnoreCase)) + return "normal"; + return value.Trim().ToLowerInvariant(); + } + public static GooseTimelineDecision Evaluate(GooseTimelineSignature? previous, GooseTimelineSignature current, bool showRetransmissions) { ArgumentNullException.ThrowIfNull(current); @@ -15,7 +45,8 @@ public static GooseTimelineDecision Evaluate(GooseTimelineSignature? previous, G var diagnosticChanged = !first && previous!.Diagnostics != current.Diagnostics; var normal = current.Sequence.Contains("Retransmission", StringComparison.OrdinalIgnoreCase) || current.Sequence.Contains("Normal", StringComparison.OrdinalIgnoreCase); - var newAnomaly = !first && previous!.Sequence != current.Sequence && !normal; + var newAnomaly = !first && SequenceFamily(previous!.Sequence) != SequenceFamily(current.Sequence) && + !normal && !string.IsNullOrWhiteSpace(current.Sequence); return new GooseTimelineDecision(first || valueChanged || stateChanged || diagnosticChanged || newAnomaly || showRetransmissions, first, valueChanged, stateChanged, diagnosticChanged, newAnomaly); } diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index 3275e9e7c..f2023c9bb 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -96,43 +96,75 @@ public static string RenderQuality(MmsDataValue value) } } -/// Pairs only exact, independently identified q and primary FCDA references. +/// +/// Exact reference-identity engineering view of GOOSE allData. Raw wire +/// entries remain separately available and untouched, including orphan q. +/// public static class GooseCanonicalLeafProjection { public static IReadOnlyList Project(IReadOnlyList wireLeaves) { - var quality = new Dictionary(StringComparer.OrdinalIgnoreCase); - var valueOwners = new HashSet(StringComparer.OrdinalIgnoreCase); + var owners = new HashSet(StringComparer.OrdinalIgnoreCase); + var quality = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var leaf in wireLeaves) { - var path = leaf.SignalReference; - if (string.IsNullOrWhiteSpace(path)) continue; - if (path.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) - quality[path[..^2]] = leaf.Value; - else if (path.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) || - path.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase)) + var key = CanonicalOwner(leaf.SignalReference, out var kind); + if (key is null) continue; + if (kind == "value") owners.Add(key); + else if (kind == "quality") { - var owner = path.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase) - ? path[..^6] : path[..^6]; - valueOwners.Add(owner); + if (!quality.TryGetValue(key, out var found)) + quality[key] = found = new List(1); + found.Add(leaf); } } + var result = new List(wireLeaves.Count); foreach (var leaf in wireLeaves) { - var reference = leaf.SignalReference; - if (!string.IsNullOrWhiteSpace(reference) && - reference.EndsWith(".q", StringComparison.OrdinalIgnoreCase) && - valueOwners.Contains(reference[..^2])) - continue; - var objectRef = reference.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase) - ? reference[..^6] : reference.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) - ? reference[..^6] : string.Empty; - result.Add(leaf with - { - Quality = objectRef.Length > 0 && quality.TryGetValue(objectRef, out var q) ? q : "—" - }); + var key = CanonicalOwner(leaf.SignalReference, out var kind); + quality.TryGetValue(key ?? string.Empty, out var companions); + var hasUniqueCompanion = key is not null && owners.Contains(key) && + companions is { Count: 1 }; + if (hasUniqueCompanion && kind == "quality") continue; + if (hasUniqueCompanion && kind == "value") + result.Add(leaf with + { + SignalName = leaf.SignalName.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) + ? leaf.SignalName[..^6] : leaf.SignalName, + Quality = companions![0].Value, + IsChanged = leaf.IsChanged || companions[0].IsChanged + }); + else result.Add(leaf); } return result; } + + private static string? CanonicalOwner(string? source, out string kind) + { + kind = string.Empty; + if (string.IsNullOrWhiteSpace(source)) return null; + var key = source.Trim().Replace('$', '.'); + var slash = key.LastIndexOf('/'); + var dot = key.IndexOf('.', slash + 1); + if (dot >= 0 && dot + 4 < key.Length && + key.AsSpan(dot + 1, 3).Equals("ST.".AsSpan(), StringComparison.OrdinalIgnoreCase)) + key = key.Remove(dot + 1, 3); + var bracket = key.LastIndexOf('['); + if (bracket >= 0 && key.EndsWith("]", StringComparison.Ordinal) && + key[(bracket + 1)..^1].Equals("ST", StringComparison.OrdinalIgnoreCase)) + key = key[..bracket]; + if (key.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) || + key.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase)) + { + kind = "value"; + return key[..^6]; + } + if (key.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) + { + kind = "quality"; + return key[..^2]; + } + return null; + } } diff --git a/Services/SntpUserSettingsStore.cs b/Services/SntpUserSettingsStore.cs new file mode 100644 index 000000000..d7ae6459f --- /dev/null +++ b/Services/SntpUserSettingsStore.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Text.Json; + +namespace ArIED61850Tester.Services; + +public sealed record SntpSavedSettings(string InterfaceId, string Ipv4Address, bool Enabled); + +/// Local and independent of station/IED project preferences. +public static class SntpUserSettingsStore +{ + public static string DefaultPath => Path.Combine(UserPreferenceStore.DefaultFolder, "sntp-server.json"); + + public static SntpSavedSettings? Load(string? path = null) + { + try + { + var value = JsonSerializer.Deserialize(File.ReadAllText(path ?? DefaultPath)); + return value is not null && IsValid(value) ? value : null; + } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + catch (JsonException) { return null; } + } + + public static bool Save(SntpSavedSettings value, string? path = null) + { + if (!IsValid(value)) return false; + var target = path ?? DefaultPath; + var tmp = target + ".tmp"; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.WriteAllText(tmp, JsonSerializer.Serialize(value)); + File.Move(tmp, target, overwrite: true); + return true; + } + catch (IOException) { return false; } + catch (UnauthorizedAccessException) { return false; } + finally { try { if (File.Exists(tmp)) File.Delete(tmp); } catch (IOException) { } } + } + + public static SntpNetworkBinding? Match(IReadOnlyList available, SntpSavedSettings? saved) + => saved is null ? null : available.FirstOrDefault(binding => + binding.InterfaceId.Equals(saved.InterfaceId, StringComparison.OrdinalIgnoreCase) && + binding.LocalAddress.ToString().Equals(saved.Ipv4Address, StringComparison.OrdinalIgnoreCase)); + + private static bool IsValid(SntpSavedSettings value) + => !string.IsNullOrWhiteSpace(value.InterfaceId) && + IPAddress.TryParse(value.Ipv4Address, out var ip) && + ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork && + !IPAddress.IsLoopback(ip); +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 533925280..50f05c81d 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -157,7 +157,7 @@ - + @@ -311,7 +311,7 @@ + Binding="{Binding Value, Converter={StaticResource GooseValueConverter}}" Width="112" MinWidth="98"> - + diff --git a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs index e680b3d1a..0476a0967 100644 --- a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs +++ b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs @@ -51,6 +51,26 @@ public void FourteenWireMembers_BecomeSevenEngineeringSignalsWithQuality() Assert.All(projected, leaf => Assert.EndsWith(".stVal", leaf.SignalReference)); } + [Fact] + public void MixedMmsFcNames_JoinOnlyExactLdAndLnMatchingQuality() + { + static GooseLeafValueSnapshot Leaf(int n,string reference,string value) + => new(n,n-1,reference[(reference.LastIndexOf('/')+1)..],reference,"ST","DPC","", + value,"",false,"SCL"); + var rows = new[] { + Leaf(1,"BCUGEF650/CSWI6$ST$Pos$stVal","Off"), + Leaf(2,"BCUGEF650/CSWI6.Pos.q","Good"), + Leaf(3,"BCUGEF650/CSWI7.Pos.stVal","Intermediate"), + Leaf(4,"BCUGEF650/CSWI7$ST$Pos$q","Questionable"), + Leaf(5,"OTHERLD/CSWI6.Pos.q","Invalid") + }; + var result = GooseCanonicalLeafProjection.Project(rows); + Assert.Equal(3,result.Count); + Assert.Equal("Good",result[0].Quality); + Assert.Equal("Questionable",result[1].Quality); + Assert.Equal("Invalid",result[2].Value); + } + [Fact] public void PreviousTypedQualityAndDbpos_AreReadableInChangeSummary() { diff --git a/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs index fb4410aa7..bb6b31a2f 100644 --- a/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs +++ b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs @@ -33,6 +33,28 @@ public void ChangedStateAndNewDiagnostic_AreVisible() Assert.True(GooseTimelineEventPolicy.Evaluate(first, first with { Diagnostics = "Bad confRev" }, false).Include); } + [Fact] + public void NumericGapAndTalChanges_AreTheSameDiagnosticEvent() + { + var a = GooseTimelineEventPolicy.DiagnosticCategory(new[] + { + "GOOSE test flag is set.", + "GOOSE supervision expired before this frame. Gap=2.303 ms, TAL=2 ms." + }); + var b = GooseTimelineEventPolicy.DiagnosticCategory(new[] + { + "GOOSE test flag is set.", + "GOOSE supervision expired before this frame. Gap=15.719 ms, TAL=4 ms." + }); + Assert.Equal(a,b); + var first = new GooseTimelineSignature("off|good","2",a,"Retransmission"); + var next = first with { Diagnostics = b, Sequence = "Normal" }; + Assert.False(GooseTimelineEventPolicy.Evaluate(first,next,false).Include); + Assert.True(GooseTimelineEventPolicy.Evaluate(first,next,true).Include); + Assert.True(GooseTimelineEventPolicy.Evaluate(first,next with { State = "3" },false).Include); + Assert.True(GooseTimelineEventPolicy.Evaluate(first,next with { Payload = "on|good" },false).Include); + } + [Fact] public void CurrentGooseUi_HasThreeSecondHighlightAndOperatorRetransmissionToggle() { diff --git a/tests/ARSAS.Tests/SntpUserSettingsStoreTests.cs b/tests/ARSAS.Tests/SntpUserSettingsStoreTests.cs new file mode 100644 index 000000000..212406f2a --- /dev/null +++ b/tests/ARSAS.Tests/SntpUserSettingsStoreTests.cs @@ -0,0 +1,57 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; +public sealed class SntpUserSettingsStoreTests +{ + [Fact] + public void PersistsLastExplicitSelectionAndEnabledState() + { + var dir = Path.Combine(Path.GetTempPath(), "arsas-sntp-test", Guid.NewGuid().ToString("N")); + try + { + var path = Path.Combine(dir,"sntp.json"); + var expected = new SntpSavedSettings("nic-id","192.168.1.4",true); + Assert.True(SntpUserSettingsStore.Save(expected,path)); + Assert.Equal(expected,SntpUserSettingsStore.Load(path)); + Assert.True(SntpUserSettingsStore.Save(expected with { Enabled = false },path)); + Assert.False(SntpUserSettingsStore.Load(path)!.Enabled); + } + finally { if(Directory.Exists(dir)) Directory.Delete(dir,true); } + } + + [Fact] + public void CorruptOrUnsafePreferenceFailsClosed() + { + var path = Path.Combine(Path.GetTempPath(),Guid.NewGuid()+".json"); + Assert.Null(SntpUserSettingsStore.Load(path)); + try + { + File.WriteAllText(path,"{ invalid"); + Assert.Null(SntpUserSettingsStore.Load(path)); + Assert.False(SntpUserSettingsStore.Save(new("","192.168.1.4",true),path)); + Assert.False(SntpUserSettingsStore.Save(new("nic-id","127.0.0.1",true),path)); + } + finally { if(File.Exists(path)) File.Delete(path); } + } + + [Fact] + public void UiRestoreRequiresExactOriginalNicAndIpv4() + { + var source = Read("MainWindow.ClockSyncToggle.cs"); + Assert.Contains("SntpUserSettingsStore.Match(choices, _savedSntpSettings)",source); + Assert.Contains("_savedSntpSettings is null && !_clockSyncEnabled",source); + Assert.Contains("SntpUserSettingsStore.Save(saved)",source); + } + + private static string Read(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while(dir is not null) + { + var full=Path.Combine(dir.FullName,path); + if(File.Exists(full)) return File.ReadAllText(full); + dir=dir.Parent; + } + throw new FileNotFoundException(path); + } +}