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
49 changes: 33 additions & 16 deletions MainWindow.GooseTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;
using ArIED61850Tester.Views;
Expand All @@ -13,13 +12,14 @@ namespace ArIED61850Tester;

public partial class MainWindow
{
private const int MaxGooseTimelineEvents = 1000;
private const int MaxPendingGooseTimelineEvents = 4096;
private const int MaxGooseTimelineEvents = 300;
private const int MaxPendingGooseTimelineEvents = 512;

private readonly ConcurrentQueue<GooseSubscriberFrameSnapshot> _pendingGooseTimeline = new();
private readonly Dictionary<string, DateTimeOffset> _lastGooseTimelineTimestamp = new(StringComparer.OrdinalIgnoreCase);
private GooseEventRow? _selectedGooseEvent;
private bool _goosePresentationInstalled;
private DateTimeOffset _nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue;

public BulkObservableCollection<GooseEventRow> GooseEvents { get; } = new();

Expand Down Expand Up @@ -50,23 +50,23 @@ internal static void RegisterGoosePresentationIntegration()
new RoutedEventHandler(OnMainWindowLoadedForGoosePresentation));
EventManager.RegisterClassHandler(
typeof(MainWindow),
GooseSubscriberView.RefreshAdaptersRequestedEvent,
GooseSubscriberLiteView.RefreshAdaptersRequestedEvent,
new RoutedEventHandler(OnRefreshGooseAdaptersRequested));
EventManager.RegisterClassHandler(
typeof(MainWindow),
GooseSubscriberView.RefreshModelsRequestedEvent,
GooseSubscriberLiteView.RefreshModelsRequestedEvent,
new RoutedEventHandler(OnRefreshGooseModelsRequested));
EventManager.RegisterClassHandler(
typeof(MainWindow),
GooseSubscriberView.StartRequestedEvent,
GooseSubscriberLiteView.StartRequestedEvent,
new RoutedEventHandler(OnStartGooseRequested));
EventManager.RegisterClassHandler(
typeof(MainWindow),
GooseSubscriberView.StopRequestedEvent,
GooseSubscriberLiteView.StopRequestedEvent,
new RoutedEventHandler(OnStopGooseRequested));
EventManager.RegisterClassHandler(
typeof(MainWindow),
GooseSubscriberView.ClearRequestedEvent,
GooseSubscriberLiteView.ClearRequestedEvent,
new RoutedEventHandler(OnClearGooseRequested));
}

Expand All @@ -87,7 +87,7 @@ private void InstallGoosePresentationWorkspace()
if (gooseTab is null)
return;

gooseTab.Content = new GooseSubscriberView { DataContext = this };
gooseTab.Content = new GooseSubscriberLiteView { DataContext = this };
_gooseSubscriberRuntime.FrameReceived += GooseTimeline_FrameReceived;
_uiFlushTimer.Tick += GooseTimelineUiFlushTimer_Tick;
PropertyChanged += GoosePresentation_PropertyChanged;
Expand Down Expand Up @@ -167,15 +167,18 @@ private static bool IsMeaningfulGooseTimelineEvent(GooseSubscriberFrameSnapshot

private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args)
{
if (_pendingGooseTimeline.IsEmpty)
var nowUtc = DateTimeOffset.UtcNow;
if (nowUtc >= _nextGooseHighlightExpiryCheckUtc)
{
Raise(nameof(GoosePublisherCountText));
Raise(nameof(GooseSelectedLeafCountText));
return;
ExpireGooseHighlights(nowUtc);
_nextGooseHighlightExpiryCheckUtc = nowUtc.AddSeconds(1);
}

if (_pendingGooseTimeline.IsEmpty)
return;

var processed = 0;
while (processed < 256 && _pendingGooseTimeline.TryDequeue(out var captured))
while (processed < 48 && _pendingGooseTimeline.TryDequeue(out var captured))
{
var stream = BuildGooseStreamSnapshot(captured, _gooseBindingCatalog);
var eventRow = BuildGooseEventRow(captured, stream);
Expand All @@ -190,6 +193,18 @@ private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args)
RaiseGoosePresentationState();
}

private void ExpireGooseHighlights(DateTimeOffset nowUtc)
{
foreach (var eventRow in GooseEvents)
eventRow.ExpireHighlight(nowUtc);

foreach (var stream in GooseStreams)
{
foreach (var leaf in stream.Leaves)
leaf.ExpireHighlight(nowUtc);
}
}

private GooseEventRow BuildGooseEventRow(
GooseSubscriberFrameSnapshot captured,
GooseStreamSnapshot stream)
Expand Down Expand Up @@ -256,8 +271,9 @@ private static string BuildGooseEventSummary(GooseStreamSnapshot stream, bool is
{
var previous = string.IsNullOrWhiteSpace(leaf.PreviousValue)
? "-"
: ShortenGooseText(leaf.PreviousValue, 30);
return $"{leaf.SignalName}: {previous} → {ShortenGooseText(leaf.Value, 36)}";
: ShortenGooseText(GooseEngineeringValueFormatter.Format(leaf.PreviousValue), 30);
var current = GooseEngineeringValueFormatter.Format(leaf.Value);
return $"{leaf.SignalName}: {previous} → {ShortenGooseText(current, 36)}";
})
.ToArray();
if (changed.Length > 0)
Expand Down Expand Up @@ -322,6 +338,7 @@ private void ResetGooseTimelineUi()
{
}
_lastGooseTimelineTimestamp.Clear();
_nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue;
GooseEvents.Clear();
SelectedGooseEvent = null;
RaiseGoosePresentationState();
Expand Down
36 changes: 36 additions & 0 deletions Models/GooseEngineeringValueFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Globalization;
using System.Text.RegularExpressions;

namespace ArIED61850Tester.Models;

public static class GooseEngineeringValueFormatter
{
private static readonly Regex CompactBitString = new(
@"^bits\((?<hex>[0-9A-Fa-f]{2}),\s*unused=(?<unused>[67])\)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);

public static string Format(string? rawValue)
{
var text = rawValue?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(text) || text == "<missing in frame>")
return text;

var match = CompactBitString.Match(text);
if (!match.Success ||
!byte.TryParse(match.Groups["hex"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value))
{
return text;
}

if (match.Groups["unused"].Value == "7")
return (value & 0x80) == 0 ? "false" : "true";

return ((value >> 6) & 0x03) switch
{
0 => "Intermediate [00]",
1 => "Open [01]",
2 => "Closed [10]",
_ => "Invalid [11]"
};
}
}
13 changes: 12 additions & 1 deletion Models/GoosePresentationModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ private static string ShortReference(string? value)
}
}

public sealed class GooseEventRow
public sealed class GooseEventRow : ObservableObject
{
private bool _isRecent = true;

public required string StreamKey { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required string DeltaText { get; init; }
Expand All @@ -71,5 +73,14 @@ public sealed class GooseEventRow
public required string StateSequenceText { get; init; }
public required string Summary { get; init; }

public bool IsRecent { get => _isRecent; private set => Set(ref _isRecent, value); }
public string TimeText => Timestamp.ToLocalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);

public bool ExpireHighlight(DateTimeOffset nowUtc)
{
if (!IsRecent || nowUtc - Timestamp < TimeSpan.FromSeconds(5))
return false;
IsRecent = false;
return true;
}
}
26 changes: 24 additions & 2 deletions Models/GooseSubscriberModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ public sealed class GooseAdapterOption
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string MacAddress { get; init; } = string.Empty;
public string FriendlyName { get; init; } = string.Empty;
public string Selector => Index.ToString(System.Globalization.CultureInfo.InvariantCulture);
public string DisplayText => $"[{Index}] {(!string.IsNullOrWhiteSpace(Description) ? Description : Name)}";
public string DetailText => string.IsNullOrWhiteSpace(MacAddress) ? Name : $"{MacAddress} • {Name}";
public string DisplayText => $"[{Index}] {FirstReadable(FriendlyName, Description, Name, "Network adapter")}";
public string DetailText => string.Join(" • ", new[] { FriendlyName, Description, MacAddress, Name }
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase));

private static string FirstReadable(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "Network adapter";
}

public sealed class GooseLeafValueRow : ObservableObject
Expand All @@ -24,6 +30,8 @@ public sealed class GooseLeafValueRow : ObservableObject
private string _previousValue = string.Empty;
private string _bindingSource = "Unbound";
private bool _isChanged;
private bool _isHighlighted;
private DateTimeOffset _highlightUntilUtc;

public int Order { get; init; }
public int DataSetIndex { get; init; }
Expand All @@ -36,6 +44,7 @@ public sealed class GooseLeafValueRow : ObservableObject
public string PreviousValue { get => _previousValue; set => Set(ref _previousValue, value ?? string.Empty); }
public string BindingSource { get => _bindingSource; set => Set(ref _bindingSource, string.IsNullOrWhiteSpace(value) ? "Unbound" : value); }
public bool IsChanged { get => _isChanged; set => Set(ref _isChanged, value); }
public bool IsHighlighted { get => _isHighlighted; private set => Set(ref _isHighlighted, value); }
public string TypeText => string.Join(" / ", new[] { Cdc, BType }.Where(item => !string.IsNullOrWhiteSpace(item)));

public void Apply(GooseLeafValueSnapshot snapshot)
Expand All @@ -49,8 +58,21 @@ public void Apply(GooseLeafValueSnapshot snapshot)
Value = snapshot.Value;
BindingSource = snapshot.BindingSource;
IsChanged = snapshot.IsChanged;
if (snapshot.IsChanged)
{
_highlightUntilUtc = DateTimeOffset.UtcNow.AddSeconds(5);
IsHighlighted = true;
Comment on lines +61 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve value highlights across retransmission coalescing

When a GOOSE state change is followed by a fast retransmission before the 100 ms UI flush, _pendingGooseFrames keeps only the last snapshot for that stream; that retransmission normally has IsChanged == false, so this branch never starts the new 5-second highlight even though the current value changed. This makes the inspector miss highlights for normal GOOSE change/retransmit traffic; preserve the recent-change flag separately or drive the highlight from the non-coalesced timeline snapshots.

Useful? React with 👍 / 👎.

}
Raise(nameof(TypeText));
}

public bool ExpireHighlight(DateTimeOffset nowUtc)
{
if (!IsHighlighted || nowUtc < _highlightUntilUtc)
return false;
IsHighlighted = false;
return true;
}
}

public sealed partial class GooseStreamRow : ObservableObject
Expand Down
68 changes: 62 additions & 6 deletions Services/GooseSubscriberRuntime.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Text.RegularExpressions;
using AR.Iec61850.Goose;
using AR.Iec61850.Monitoring;
using AR.Iec61850.Scl;
Expand Down Expand Up @@ -58,15 +60,23 @@ public bool IsRunning
}

public IReadOnlyList<GooseAdapterOption> ListAdapters()
=> NpcapAdapterCatalog.ListAdapters()
.Select(adapter => new GooseAdapterOption
{
var windowsAdapters = NetworkInterface.GetAllNetworkInterfaces();
return NpcapAdapterCatalog.ListAdapters()
.Select(adapter =>
{
Index = adapter.Index,
Name = adapter.Name,
Description = adapter.Description,
MacAddress = adapter.MacAddress?.ToString() ?? string.Empty
var macAddress = adapter.MacAddress?.ToString() ?? string.Empty;
return new GooseAdapterOption
{
Index = adapter.Index,
Name = adapter.Name,
Description = adapter.Description,
MacAddress = macAddress,
FriendlyName = ResolveAdapterFriendlyName(adapter.Name, adapter.Description, macAddress, windowsAdapters)
};
})
.ToArray();
}

public Task StartAsync(
string adapterSelector,
Expand Down Expand Up @@ -194,6 +204,52 @@ private async Task CaptureLoopAsync(
}
}

private static string ResolveAdapterFriendlyName(
string captureName,
string captureDescription,
string macAddress,
IReadOnlyList<NetworkInterface> windowsAdapters)
{
var normalizedMac = NormalizeMac(macAddress);
var captureId = ExtractAdapterId(captureName);
var match = windowsAdapters.FirstOrDefault(adapter =>
(!string.IsNullOrWhiteSpace(normalizedMac) && NormalizeMac(adapter.GetPhysicalAddress().ToString()) == normalizedMac) ||
(!string.IsNullOrWhiteSpace(captureId) && adapter.Id.Equals(captureId, StringComparison.OrdinalIgnoreCase)));

if (match is not null)
{
var windowsName = CleanAdapterLabel(match.Name);
if (!string.IsNullOrWhiteSpace(windowsName))
return windowsName;
var windowsDescription = CleanAdapterLabel(match.Description);
if (!string.IsNullOrWhiteSpace(windowsDescription))
return windowsDescription;
}

return FirstAdapterLabel(CleanAdapterLabel(captureDescription), CleanAdapterLabel(captureName), "Network adapter");
}

private static string ExtractAdapterId(string? value)
{
var match = Regex.Match(value ?? string.Empty, @"\{(?<id>[0-9A-Fa-f-]{36})\}");
return match.Success ? match.Groups["id"].Value : string.Empty;
}

private static string NormalizeMac(string? value)
=> Regex.Replace(value ?? string.Empty, "[^0-9A-Fa-f]", string.Empty).ToUpperInvariant();

private static string CleanAdapterLabel(string? value)
{
var text = value?.Trim() ?? string.Empty;
if (text.Equals("ArIED61850", StringComparison.OrdinalIgnoreCase) ||
text.Equals("ArIED 61850", StringComparison.OrdinalIgnoreCase))
return string.Empty;
return text;
}

private static string FirstAdapterLabel(params string[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "Network adapter";

private void PublishStatus(bool running, string message, string level = "INFO")
=> StatusChanged?.Invoke(new GooseSubscriberStatusSnapshot
{
Expand Down
14 changes: 14 additions & 0 deletions Views/GooseEngineeringValueConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Globalization;
using System.Windows.Data;
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Views;

public sealed class GooseEngineeringValueConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> GooseEngineeringValueFormatter.Format(value?.ToString());

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
Loading
Loading