Skip to content
Closed
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
21 changes: 20 additions & 1 deletion MainWindow.ClockSyncToggle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()
Expand All @@ -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
{
Expand All @@ -222,6 +228,7 @@ private async void GlobalSntpIp_SelectionChanged(object sender, SelectionChanged
}
else
PublishGlobalSntpUiState();
SaveSntpPreference();
}

private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e)
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion MainWindow.GooseTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion Services/GooseTimelineEventPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ public sealed record GooseTimelineSignature(string Payload, string State, string
/// <summary>Presentation-only filter. All packets still enter the read-only wire capture.</summary>
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<string> diagnostics)
{
var categories = new HashSet<string>(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);
Expand All @@ -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);
}
Expand Down
80 changes: 56 additions & 24 deletions Services/GooseTypedValueInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,43 +96,75 @@ public static string RenderQuality(MmsDataValue value)
}
}

/// <summary>Pairs only exact, independently identified q and primary FCDA references.</summary>
/// <summary>
/// Exact reference-identity engineering view of GOOSE allData. Raw wire
/// entries remain separately available and untouched, including orphan q.
/// </summary>
public static class GooseCanonicalLeafProjection
{
public static IReadOnlyList<GooseLeafValueSnapshot> Project(IReadOnlyList<GooseLeafValueSnapshot> wireLeaves)
{
var quality = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var valueOwners = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var owners = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var quality = new Dictionary<string, List<GooseLeafValueSnapshot>>(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<GooseLeafValueSnapshot>(1);
found.Add(leaf);
}
}

var result = new List<GooseLeafValueSnapshot>(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;
}
}
52 changes: 52 additions & 0 deletions Services/SntpUserSettingsStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Net;
using System.Text.Json;

namespace ArIED61850Tester.Services;

public sealed record SntpSavedSettings(string InterfaceId, string Ipv4Address, bool Enabled);

/// <summary>Local and independent of station/IED project preferences.</summary>
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<SntpSavedSettings>(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<SntpNetworkBinding> 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);
}
22 changes: 17 additions & 5 deletions Views/GooseSubscriberLiteView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="420"/>
<ColumnDefinition Width="9"/>
<ColumnDefinition Width="360" MinWidth="315"/>
<ColumnDefinition Width="420" MinWidth="370"/>
</Grid.ColumnDefinitions>

<Border Grid.Column="0" Style="{StaticResource Card}" Padding="10" Visibility="Collapsed">
Expand Down Expand Up @@ -311,7 +311,7 @@
<DataGrid ItemsSource="{Binding GooseEvents}"
SelectedItem="{Binding SelectedGooseEvent, Mode=TwoWay}"
IsReadOnly="True" Style="{StaticResource ModernDataGrid}"
AutoGenerateColumns="False" RowHeight="38"
AutoGenerateColumns="False" RowHeight="32"
EnableRowVirtualization="True"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
Expand Down Expand Up @@ -578,7 +578,7 @@
<DataGrid ItemsSource="{Binding SelectedGooseStream.EngineeringLeaves}"
IsReadOnly="True" AutoGenerateColumns="False"
Style="{StaticResource ModernDataGrid}"
HeadersVisibility="Column" RowHeight="27" ColumnHeaderHeight="28"
HeadersVisibility="Column" RowHeight="30" ColumnHeaderHeight="30"
MinHeight="55" MaxHeight="440"
EnableRowVirtualization="True"
VirtualizingPanel.IsVirtualizing="True"
Expand All @@ -604,19 +604,31 @@
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="Value"
Binding="{Binding Value, Converter={StaticResource GooseValueConverter}}" Width="90" MinWidth="65">
Binding="{Binding Value, Converter={StaticResource GooseValueConverter}}" Width="112" MinWidth="98">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="ToolTip" Value="{Binding Value}"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="Quality" Binding="{Binding Quality}" Width="83" MinWidth="70">
<DataGridTextColumn Header="Quality" Binding="{Binding Quality}" Width="100" MinWidth="86">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="ToolTip" Value="{Binding Quality}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Quality}" Value="Good">
<Setter Property="Foreground" Value="#067647"/>
</DataTrigger>
<DataTrigger Binding="{Binding Quality}" Value="Invalid">
<Setter Property="Foreground" Value="#B42318"/>
</DataTrigger>
<DataTrigger Binding="{Binding Quality}" Value="Questionable">
<Setter Property="Foreground" Value="#B54708"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
Expand Down
20 changes: 20 additions & 0 deletions tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading
Loading