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);
+ }
+}