diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 7fb81b238..ef9770c30 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -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; @@ -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 _pendingGooseTimeline = new(); private readonly Dictionary _lastGooseTimelineTimestamp = new(StringComparer.OrdinalIgnoreCase); private GooseEventRow? _selectedGooseEvent; private bool _goosePresentationInstalled; + private DateTimeOffset _nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue; public BulkObservableCollection GooseEvents { get; } = new(); @@ -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)); } @@ -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; @@ -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); @@ -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) @@ -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) @@ -322,6 +338,7 @@ private void ResetGooseTimelineUi() { } _lastGooseTimelineTimestamp.Clear(); + _nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue; GooseEvents.Clear(); SelectedGooseEvent = null; RaiseGoosePresentationState(); diff --git a/Models/GooseEngineeringValueFormatter.cs b/Models/GooseEngineeringValueFormatter.cs new file mode 100644 index 000000000..f7dab508e --- /dev/null +++ b/Models/GooseEngineeringValueFormatter.cs @@ -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\((?[0-9A-Fa-f]{2}),\s*unused=(?[67])\)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static string Format(string? rawValue) + { + var text = rawValue?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text) || text == "") + 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]" + }; + } +} diff --git a/Models/GoosePresentationModels.cs b/Models/GoosePresentationModels.cs index 4071c4c7b..5e4e04abe 100644 --- a/Models/GoosePresentationModels.cs +++ b/Models/GoosePresentationModels.cs @@ -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; } @@ -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; + } } diff --git a/Models/GooseSubscriberModels.cs b/Models/GooseSubscriberModels.cs index a72e85a4b..1737e0e08 100644 --- a/Models/GooseSubscriberModels.cs +++ b/Models/GooseSubscriberModels.cs @@ -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 @@ -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; } @@ -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) @@ -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; + } Raise(nameof(TypeText)); } + + public bool ExpireHighlight(DateTimeOffset nowUtc) + { + if (!IsHighlighted || nowUtc < _highlightUntilUtc) + return false; + IsHighlighted = false; + return true; + } } public sealed partial class GooseStreamRow : ObservableObject diff --git a/Services/GooseSubscriberRuntime.cs b/Services/GooseSubscriberRuntime.cs index 6c492aabf..3302624c1 100644 --- a/Services/GooseSubscriberRuntime.cs +++ b/Services/GooseSubscriberRuntime.cs @@ -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; @@ -58,15 +60,23 @@ public bool IsRunning } public IReadOnlyList 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, @@ -194,6 +204,52 @@ private async Task CaptureLoopAsync( } } + private static string ResolveAdapterFriendlyName( + string captureName, + string captureDescription, + string macAddress, + IReadOnlyList 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, @"\{(?[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 { diff --git a/Views/GooseEngineeringValueConverter.cs b/Views/GooseEngineeringValueConverter.cs new file mode 100644 index 000000000..7454eb537 --- /dev/null +++ b/Views/GooseEngineeringValueConverter.cs @@ -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; +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml new file mode 100644 index 000000000..484d14bcd --- /dev/null +++ b/Views/GooseSubscriberLiteView.xaml @@ -0,0 +1,631 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/GooseSubscriberLiteView.xaml.cs b/Views/GooseSubscriberLiteView.xaml.cs new file mode 100644 index 000000000..8cf26a8e1 --- /dev/null +++ b/Views/GooseSubscriberLiteView.xaml.cs @@ -0,0 +1,72 @@ +using System.Windows; +using System.Windows.Controls; + +namespace ArIED61850Tester.Views; + +public partial class GooseSubscriberLiteView : UserControl +{ + public static readonly RoutedEvent RefreshAdaptersRequestedEvent = EventManager.RegisterRoutedEvent( + nameof(RefreshAdaptersRequested), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GooseSubscriberLiteView)); + + public static readonly RoutedEvent RefreshModelsRequestedEvent = EventManager.RegisterRoutedEvent( + nameof(RefreshModelsRequested), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GooseSubscriberLiteView)); + + public static readonly RoutedEvent StartRequestedEvent = EventManager.RegisterRoutedEvent( + nameof(StartRequested), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GooseSubscriberLiteView)); + + public static readonly RoutedEvent StopRequestedEvent = EventManager.RegisterRoutedEvent( + nameof(StopRequested), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GooseSubscriberLiteView)); + + public static readonly RoutedEvent ClearRequestedEvent = EventManager.RegisterRoutedEvent( + nameof(ClearRequested), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GooseSubscriberLiteView)); + + public GooseSubscriberLiteView() + { + InitializeComponent(); + } + + public event RoutedEventHandler RefreshAdaptersRequested + { + add => AddHandler(RefreshAdaptersRequestedEvent, value); + remove => RemoveHandler(RefreshAdaptersRequestedEvent, value); + } + + public event RoutedEventHandler RefreshModelsRequested + { + add => AddHandler(RefreshModelsRequestedEvent, value); + remove => RemoveHandler(RefreshModelsRequestedEvent, value); + } + + public event RoutedEventHandler StartRequested + { + add => AddHandler(StartRequestedEvent, value); + remove => RemoveHandler(StartRequestedEvent, value); + } + + public event RoutedEventHandler StopRequested + { + add => AddHandler(StopRequestedEvent, value); + remove => RemoveHandler(StopRequestedEvent, value); + } + + public event RoutedEventHandler ClearRequested + { + add => AddHandler(ClearRequestedEvent, value); + remove => RemoveHandler(ClearRequestedEvent, value); + } + + private void RefreshAdapters_Click(object sender, RoutedEventArgs e) + => RaiseEvent(new RoutedEventArgs(RefreshAdaptersRequestedEvent, this)); + + private void RefreshModels_Click(object sender, RoutedEventArgs e) + => RaiseEvent(new RoutedEventArgs(RefreshModelsRequestedEvent, this)); + + private void StartCapture_Click(object sender, RoutedEventArgs e) + => RaiseEvent(new RoutedEventArgs(StartRequestedEvent, this)); + + private void StopCapture_Click(object sender, RoutedEventArgs e) + => RaiseEvent(new RoutedEventArgs(StopRequestedEvent, this)); + + private void ClearCapture_Click(object sender, RoutedEventArgs e) + => RaiseEvent(new RoutedEventArgs(ClearRequestedEvent, this)); +}