diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54f6f3d..e34e1eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,8 @@ on: workflow_dispatch: env: - ARIEC61850_REF: main + ARIEC61850_REF: agent/sv-core-unification + ARIEC61850_COMMIT: fcc05de1f4b11560195e2589aa032e1d4c4b95f7 jobs: build-windows: @@ -24,7 +25,7 @@ jobs: shell: powershell run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 - - name: Verify premium UX, GOOSE and SAS invariants + - name: Verify premium UX, GOOSE, SMV and SAS invariants shell: powershell run: | $app = Get-Content .\ArIED61850Tester\App.xaml -Raw @@ -40,6 +41,9 @@ jobs: $gooseUi = Get-Content .\ArIED61850Tester\MainWindow.GooseSubscriber.cs -Raw $gooseRuntime = Get-Content .\ArIED61850Tester\Services\GooseSubscriberRuntime.cs -Raw $gooseModels = Get-Content .\ArIED61850Tester\Models\GooseSubscriberModels.cs -Raw + $smvUi = Get-Content .\ArIED61850Tester\SmvViewerWindow.xaml -Raw + $smvCode = Get-Content .\ArIED61850Tester\SmvViewerWindow.xaml.cs -Raw + $smvRuntime = Get-Content .\ArIED61850Tester\Services\SmvSnapshotCaptureService.cs -Raw if ($save -notmatch 'x:Name="EditionIndicator"' -or $save -notmatch 'Content="Edition 2"' -or @@ -111,6 +115,20 @@ jobs: throw "SCL/discovery-aware GOOSE Subscriber runtime, canonical leaf order, or semantic metadata binding regressed." } + if ($smvUi -notmatch 'Content="Capture 2 cycles"' -or + $smvUi -notmatch 'x:Name="WaveformCanvas"' -or + $smvUi -notmatch 'Static waveform proof' -or + $smvCode -notmatch 'SmvSnapshotCaptureService' -or + $smvCode -notmatch 'smpCnt' -or + $smvCode -notmatch 'Raw lanes remain semantically unresolved' -or + $smvRuntime -notmatch 'SampledValuesFrameParser.TryParseEthernetFrame' -or + $smvRuntime -notmatch 'SvGenericAsduInspector' -or + $smvRuntime -notmatch 'SvSampleCounterTracker' -or + $smvRuntime -notmatch 'SvTimebaseResolver' -or + $smvRuntime -notmatch 'ether proto 0x88ba') { + throw "Bounded two-cycle SMV snapshot capture, raw waveform, timebase, or continuity evidence regressed." + } + if ($sasPolicy -notmatch 'public static bool IsVisible' -or $sasPolicy -notmatch 'IsOperationalProtectionLeaf' -or $sasPolicy -notmatch 'IsFundamentalMeasurement' -or @@ -153,6 +171,8 @@ jobs: ArIED61850Tester/MainWindow.CommandPanelUx.cs ArIED61850Tester/MainWindow.ControlDiagnostics.cs ArIED61850Tester/MainWindow.GooseSubscriber.cs + ArIED61850Tester/SmvViewerWindow.xaml + ArIED61850Tester/SmvViewerWindow.xaml.cs ArIED61850Tester/SaveSclWindow.xaml ArIED61850Tester/SaveSclWindow.xaml.cs ArIED61850Tester/GridUxBehavior.cs @@ -161,9 +181,14 @@ jobs: ArIED61850Tester/docs ArIED61850Tester/landing - - name: Checkout ARIEC61850 engine + - name: Checkout pinned ARIEC61850 SV engine shell: powershell - run: git clone --quiet --depth 1 --branch $env:ARIEC61850_REF https://github.com/masarray/ARIEC61850.git ARIEC61850 + run: | + git clone --quiet --depth 1 --branch $env:ARIEC61850_REF https://github.com/masarray/ARIEC61850.git ARIEC61850 + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { + throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + } - name: Verify required ARIEC61850 APIs shell: powershell @@ -179,6 +204,10 @@ jobs: $gooseParser = ".\ARIEC61850\src\AR.Iec61850\Goose\GooseFrameParser.cs" $gooseDecoded = ".\ARIEC61850\src\AR.Iec61850\Goose\GooseDecodedValue.cs" $processBusMonitor = ".\ARIEC61850\src\AR.Iec61850\Monitoring\ProcessBusStreamMonitor.cs" + $svParser = ".\ARIEC61850\src\AR.Iec61850\SampledValues\SampledValuesFrameParser.cs" + $svInspector = ".\ARIEC61850\src\AR.Iec61850\SampledValues\Analysis\SvGenericAsduInspector.cs" + $svCounter = ".\ARIEC61850\src\AR.Iec61850\SampledValues\Measurements\SvSampleCounterTracker.cs" + $svTimebase = ".\ARIEC61850\src\AR.Iec61850\SampledValues\Measurements\SvTimebaseResolver.cs" $npcapSource = ".\ARIEC61850\src\AR.Iec61850.Transports.Npcap\NpcapProcessBusFrameSource.cs" $npcapProject = ".\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" @@ -209,14 +238,22 @@ jobs: if (!(Test-Path $gooseParser) -or !(Test-Path $gooseDecoded) -or !(Test-Path $processBusMonitor) -or + !(Test-Path $svParser) -or + !(Test-Path $svInspector) -or + !(Test-Path $svCounter) -or + !(Test-Path $svTimebase) -or !(Test-Path $npcapSource) -or !(Test-Path $npcapProject) -or !(Select-String -Path $gooseParser -Pattern "TryParseEthernetFrame" -Quiet) -or !(Select-String -Path $gooseDecoded -Pattern "SignalReference" -Quiet) -or !(Select-String -Path $processBusMonitor -Pattern "GooseValues" -Quiet) -or !(Select-String -Path $processBusMonitor -Pattern "TimeAllowedToLiveMilliseconds" -Quiet) -or + !(Select-String -Path $svParser -Pattern "TryParseEthernetFrame" -Quiet) -or + !(Select-String -Path $svInspector -Pattern "SvGenericPayloadInspector" -Quiet) -or + !(Select-String -Path $svCounter -Pattern "SvSampleCounterTransitionKind" -Quiet) -or + !(Select-String -Path $svTimebase -Pattern "TrustedNominalFrequencyHz" -Quiet) -or !(Select-String -Path $npcapSource -Pattern "CaptureAsync" -Quiet)) { - throw "Required ARIEC61850 live GOOSE subscribe, ordered decode, supervision, or Npcap APIs were not found." + throw "Required ARIEC61850 live process-bus, SV parser/inspection/timebase/counter, or Npcap APIs were not found." } - name: Setup .NET 8 diff --git a/Services/SmvSnapshotCaptureService.cs b/Services/SmvSnapshotCaptureService.cs new file mode 100644 index 0000000..6ba8275 --- /dev/null +++ b/Services/SmvSnapshotCaptureService.cs @@ -0,0 +1,520 @@ +using System.Collections.Concurrent; +using System.Net.NetworkInformation; +using System.Text.RegularExpressions; +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using AR.Iec61850.SampledValues.Measurements; +using AR.Iec61850.Transports; +using AR.Iec61850.Transports.Npcap; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public sealed record SmvSnapshotRequest +{ + public required string AdapterSelector { get; init; } + public ushort? AppId { get; init; } + public string StreamId { get; init; } = string.Empty; + public string DestinationMac { get; init; } = string.Empty; + public ushort? DeclaredSampleRateHint { get; init; } + public ushort? DeclaredSampleModeHint { get; init; } + public double TrustedNominalFrequencyHz { get; init; } = 50.0; + public int CycleCount { get; init; } = 2; + public int MaximumChannels { get; init; } = 8; + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(8); +} + +public sealed record SmvSnapshotProgress +{ + public int CapturedSamples { get; init; } + public int TargetSamples { get; init; } + public long CapturedFrames { get; init; } + public string Message { get; init; } = string.Empty; +} + +public sealed record SmvSnapshotChannel +{ + public int ChannelIndex { get; init; } + public int PayloadWordIndex { get; init; } + public string Label { get; init; } = string.Empty; + public string Interpretation { get; init; } = string.Empty; + public IReadOnlyList Samples { get; init; } = Array.Empty(); + public double Minimum { get; init; } + public double Maximum { get; init; } + public double PeakToPeak => Maximum - Minimum; +} + +public sealed record SmvSnapshotResult +{ + public DateTimeOffset StartedAt { get; init; } + public DateTimeOffset CompletedAt { get; init; } + public ushort AppId { get; init; } + public string SourceMac { get; init; } = string.Empty; + public string DestinationMac { get; init; } = string.Empty; + public string VlanText { get; init; } = "-"; + public string StreamId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint ConfigurationRevision { get; init; } + public byte SampleSynchronization { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double NominalFrequencyHz { get; init; } + public int SamplesPerCycle { get; init; } + public int CycleCount { get; init; } + public int TargetSamples { get; init; } + public int CapturedSamples { get; init; } + public long CapturedFrames { get; init; } + public long ParsedAsdus { get; init; } + public ushort FirstSampleCount { get; init; } + public ushort LastSampleCount { get; init; } + public int ContinuousTransitions { get; init; } + public int NormalWraps { get; init; } + public int GapTransitions { get; init; } + public int MissingSamples { get; init; } + public int DuplicateTransitions { get; init; } + public int OutOfOrderTransitions { get; init; } + public int RestartTransitions { get; init; } + public string TimebaseReason { get; init; } = string.Empty; + public string PayloadShape { get; init; } = string.Empty; + public IReadOnlyList Channels { get; init; } = Array.Empty(); + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); + + public bool HasCounterAnomaly => GapTransitions > 0 || DuplicateTransitions > 0 || OutOfOrderTransitions > 0; + public bool IsComplete => CapturedSamples >= TargetSamples; + public bool IsCleanProof => IsComplete && !HasCounterAnomaly; + public TimeSpan CaptureDuration => CompletedAt - StartedAt; +} + +/// +/// Read-only bounded IEC 61850-9-2 capture used to prove that one selected SV stream can be +/// received, parsed and followed for a fixed two-cycle observation window. Protocol parsing, +/// generic payload inspection and sample-counter analysis remain engine-owned. +/// +public sealed class SmvSnapshotCaptureService +{ + public const string DefaultCaptureFilter = "ether proto 0x88ba or (vlan and ether proto 0x88ba)"; + + public IReadOnlyList ListAdapters() + { + var windowsAdapters = NetworkInterface.GetAllNetworkInterfaces(); + return NpcapAdapterCatalog.ListAdapters() + .Select(adapter => + { + 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 async Task CaptureAsync( + SmvSnapshotRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.AdapterSelector); + if (request.CycleCount is < 1 or > 10) + throw new ArgumentOutOfRangeException(nameof(request.CycleCount)); + if (request.MaximumChannels is < 1 or > 32) + throw new ArgumentOutOfRangeException(nameof(request.MaximumChannels)); + if (request.TrustedNominalFrequencyHz is not (50.0 or 60.0)) + throw new ArgumentOutOfRangeException(nameof(request.TrustedNominalFrequencyHz), "Snapshot frequency must be explicitly set to 50 or 60 Hz."); + + using var timeoutCancellation = new CancellationTokenSource(request.Timeout); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCancellation.Token); + var token = linkedCancellation.Token; + var startedAt = DateTimeOffset.UtcNow; + + var samplesByChannel = new List>(); + var channelWordIndexes = new List(); + var diagnostics = new List(); + var counterTracker = new SvSampleCounterTracker(); + SvTimebaseResolution? timebase = null; + SvGenericPayloadInspection? firstPayload = null; + SampledValuesFrame? firstFrame = null; + SampledValueAsdu? firstAsdu = null; + SampledValueAsdu? lastAsdu = null; + int targetSamples = 0; + int acceptedSamples = 0; + long capturedFrames = 0; + long parsedAsdus = 0; + int continuous = 0; + int normalWraps = 0; + int gaps = 0; + int missing = 0; + int duplicates = 0; + int outOfOrder = 0; + int restarts = 0; + + try + { + using var source = new NpcapProcessBusFrameSource(request.AdapterSelector); + var options = new ProcessBusCaptureOptions + { + Filter = DefaultCaptureFilter, + ReadTimeoutMilliseconds = 250, + BufferCapacity = 8192 + }; + + progress?.Report(new SmvSnapshotProgress + { + Message = "Listening for the selected IEC 61850-9-2 stream…" + }); + + await foreach (var captured in source.CaptureAsync(options, token).ConfigureAwait(false)) + { + if (!SampledValuesFrameParser.TryParseEthernetFrame(captured.Frame, out var frame) || + !MatchesFrame(request, frame)) + { + continue; + } + + var matchedFrame = false; + foreach (var asdu in frame.Pdu.Asdus) + { + if (!MatchesAsdu(request, asdu)) + continue; + + matchedFrame = true; + parsedAsdus++; + var inspection = SvGenericAsduInspector.Inspect(asdu).Payload; + if (inspection.CompleteWordCount == 0) + { + diagnostics.Add($"ASDU smpCnt={asdu.SampleCount} contained no complete 32-bit seqOfData word."); + continue; + } + + if (timebase is null) + { + timebase = ResolveTimebase(request, asdu, inspection); + if (!timebase.IsResolved || timebase.SamplesPerCycle is not > 0) + { + throw new InvalidDataException( + $"The two-cycle target cannot be resolved from smpRate/smpMod and the explicit {request.TrustedNominalFrequencyHz:0} Hz setting. {timebase.Reason}"); + } + + targetSamples = checked(timebase.SamplesPerCycle.Value * request.CycleCount); + firstFrame = frame; + firstAsdu = asdu; + firstPayload = inspection; + InitializeChannels(inspection, request.MaximumChannels, samplesByChannel, channelWordIndexes); + diagnostics.AddRange(inspection.Diagnostics); + } + + ValidateStablePayload(firstPayload!, inspection, channelWordIndexes); + AppendSample(inspection, channelWordIndexes, samplesByChannel); + acceptedSamples++; + lastAsdu = asdu; + + var transition = counterTracker.Observe(asdu.SampleCount, timebase.SampleCounterWrap); + switch (transition.Kind) + { + case SvSampleCounterTransitionKind.Continuous: + continuous++; + break; + case SvSampleCounterTransitionKind.NormalWrap: + normalWraps++; + break; + case SvSampleCounterTransitionKind.Gap: + gaps++; + missing += transition.MissingSamples; + diagnostics.Add(transition.Detail); + break; + case SvSampleCounterTransitionKind.Duplicate: + duplicates++; + diagnostics.Add(transition.Detail); + break; + case SvSampleCounterTransitionKind.OutOfOrder: + outOfOrder++; + diagnostics.Add(transition.Detail); + break; + case SvSampleCounterTransitionKind.Restart: + restarts++; + diagnostics.Add(transition.Detail); + break; + } + + progress?.Report(new SmvSnapshotProgress + { + CapturedSamples = acceptedSamples, + TargetSamples = targetSamples, + CapturedFrames = capturedFrames + 1, + Message = $"Decoded {acceptedSamples:N0} / {targetSamples:N0} samples" + }); + + if (acceptedSamples >= targetSamples) + { + capturedFrames++; + return BuildResult( + request, + startedAt, + DateTimeOffset.UtcNow, + firstFrame!, + firstAsdu!, + lastAsdu!, + firstPayload!, + timebase, + samplesByChannel, + channelWordIndexes, + acceptedSamples, + capturedFrames, + parsedAsdus, + continuous, + normalWraps, + gaps, + missing, + duplicates, + outOfOrder, + restarts, + diagnostics); + } + } + + if (matchedFrame) + capturedFrames++; + } + } + catch (OperationCanceledException) when (timeoutCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException(BuildTimeoutMessage(request, capturedFrames, acceptedSamples, targetSamples)); + } + + throw new InvalidDataException(BuildTimeoutMessage(request, capturedFrames, acceptedSamples, targetSamples)); + } + + private static SvTimebaseResolution ResolveTimebase( + SmvSnapshotRequest request, + SampledValueAsdu asdu, + SvGenericPayloadInspection payload) + { + var declaredRate = asdu.SampleRate ?? request.DeclaredSampleRateHint; + var declaredMode = asdu.SampleMode ?? request.DeclaredSampleModeHint; + return SvTimebaseResolver.Resolve(new SvTimebaseEvidence + { + DeclaredSampleRate = declaredRate, + DeclaredSampleMode = declaredMode, + IsFixedLegacyProtectionLayout = payload.HasEightByteGroupShape && payload.PayloadLength == 64, + TrustedNominalFrequencyHz = request.TrustedNominalFrequencyHz + }); + } + + private static void InitializeChannels( + SvGenericPayloadInspection inspection, + int maximumChannels, + ICollection> samplesByChannel, + ICollection channelWordIndexes) + { + var candidateWords = inspection.HasEightByteGroupShape + ? inspection.Words.Where(word => word.StructuralRole == SvGenericPayloadWordRole.FirstWordInEightByteGroup) + : inspection.Words; + + foreach (var word in candidateWords.Take(maximumChannels)) + { + channelWordIndexes.Add(word.Index); + samplesByChannel.Add(new List()); + } + + if (channelWordIndexes.Count == 0) + throw new InvalidDataException("No plottable 32-bit seqOfData word was found in the selected SV ASDU."); + } + + private static void ValidateStablePayload( + SvGenericPayloadInspection first, + SvGenericPayloadInspection current, + IReadOnlyCollection channelWordIndexes) + { + if (current.PayloadLength != first.PayloadLength || + current.CompleteWordCount != first.CompleteWordCount || + channelWordIndexes.Any(index => index >= current.Words.Count)) + { + throw new InvalidDataException( + $"The selected stream changed seqOfData shape inside the snapshot window ({first.PayloadLength} → {current.PayloadLength} bytes)."); + } + } + + private static void AppendSample( + SvGenericPayloadInspection inspection, + IReadOnlyList channelWordIndexes, + IReadOnlyList> samplesByChannel) + { + for (var index = 0; index < channelWordIndexes.Count; index++) + samplesByChannel[index].Add(inspection.Words[channelWordIndexes[index]].SignedInt32); + } + + private static SmvSnapshotResult BuildResult( + SmvSnapshotRequest request, + DateTimeOffset startedAt, + DateTimeOffset completedAt, + SampledValuesFrame firstFrame, + SampledValueAsdu firstAsdu, + SampledValueAsdu lastAsdu, + SvGenericPayloadInspection firstPayload, + SvTimebaseResolution timebase, + IReadOnlyList> samplesByChannel, + IReadOnlyList channelWordIndexes, + int acceptedSamples, + long capturedFrames, + long parsedAsdus, + int continuous, + int normalWraps, + int gaps, + int missing, + int duplicates, + int outOfOrder, + int restarts, + IReadOnlyList diagnostics) + { + var channels = samplesByChannel.Select((samples, index) => + { + var wordIndex = channelWordIndexes[index]; + return new SmvSnapshotChannel + { + ChannelIndex = index + 1, + PayloadWordIndex = wordIndex, + Label = firstPayload.HasEightByteGroupShape + ? $"Raw group {index + 1} · word {wordIndex + 1}" + : $"Raw word {wordIndex + 1}", + Interpretation = firstPayload.HasEightByteGroupShape + ? "Signed INT32, first word in structural 8-byte group; channel/unit semantics unresolved" + : "Signed INT32 representation; channel/unit semantics unresolved", + Samples = samples.ToArray(), + Minimum = samples.Min(), + Maximum = samples.Max() + }; + }).ToArray(); + + return new SmvSnapshotResult + { + StartedAt = startedAt, + CompletedAt = completedAt, + AppId = firstFrame.AppId, + SourceMac = firstFrame.Source.ToString(), + DestinationMac = firstFrame.Destination.ToString(), + VlanText = firstFrame.Vlan?.VlanId.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-", + StreamId = firstAsdu.SvId, + DataSetReference = firstAsdu.DataSetReference, + ConfigurationRevision = firstAsdu.ConfigurationRevision, + SampleSynchronization = firstAsdu.SampleSynchronization, + DeclaredSampleRate = firstAsdu.SampleRate ?? request.DeclaredSampleRateHint, + DeclaredSampleMode = firstAsdu.SampleMode ?? request.DeclaredSampleModeHint, + NominalFrequencyHz = timebase.NominalFrequencyHz ?? request.TrustedNominalFrequencyHz, + SamplesPerCycle = timebase.SamplesPerCycle!.Value, + CycleCount = request.CycleCount, + TargetSamples = timebase.SamplesPerCycle.Value * request.CycleCount, + CapturedSamples = acceptedSamples, + CapturedFrames = capturedFrames, + ParsedAsdus = parsedAsdus, + FirstSampleCount = firstAsdu.SampleCount, + LastSampleCount = lastAsdu.SampleCount, + ContinuousTransitions = continuous, + NormalWraps = normalWraps, + GapTransitions = gaps, + MissingSamples = missing, + DuplicateTransitions = duplicates, + OutOfOrderTransitions = outOfOrder, + RestartTransitions = restarts, + TimebaseReason = timebase.Reason, + PayloadShape = firstPayload.HasEightByteGroupShape + ? $"{firstPayload.PayloadLength} bytes · {firstPayload.PayloadLength / 8} structural 8-byte group(s)" + : $"{firstPayload.PayloadLength} bytes · {firstPayload.CompleteWordCount} raw 32-bit word(s)", + Channels = channels, + Diagnostics = diagnostics.Distinct(StringComparer.Ordinal).ToArray() + }; + } + + private static bool MatchesFrame(SmvSnapshotRequest request, SampledValuesFrame frame) + { + if (request.AppId.HasValue && frame.AppId != request.AppId.Value) + return false; + + var expectedMac = NormalizeMac(request.DestinationMac); + return string.IsNullOrWhiteSpace(expectedMac) || + expectedMac == NormalizeMac(frame.Destination.ToString()); + } + + private static bool MatchesAsdu(SmvSnapshotRequest request, SampledValueAsdu asdu) + { + var expected = NormalizeIdentity(request.StreamId); + if (string.IsNullOrWhiteSpace(expected)) + return true; + return NormalizeIdentity(asdu.SvId).Equals(expected, StringComparison.OrdinalIgnoreCase); + } + + private static string BuildTimeoutMessage( + SmvSnapshotRequest request, + long capturedFrames, + int acceptedSamples, + int targetSamples) + { + var identity = string.Join(", ", new[] + { + request.AppId.HasValue ? $"APPID 0x{request.AppId.Value:X4}" : string.Empty, + string.IsNullOrWhiteSpace(NormalizeIdentity(request.StreamId)) ? string.Empty : $"svID {request.StreamId}", + string.IsNullOrWhiteSpace(NormalizeMac(request.DestinationMac)) ? string.Empty : $"MAC {request.DestinationMac}" + }.Where(value => !string.IsNullOrWhiteSpace(value))); + if (string.IsNullOrWhiteSpace(identity)) + identity = "the first decodable SV stream"; + + var target = targetSamples > 0 ? $"{acceptedSamples:N0}/{targetSamples:N0} samples" : $"{acceptedSamples:N0} samples"; + return $"Snapshot did not complete within {request.Timeout.TotalSeconds:0.#} s for {identity}. Received {capturedFrames:N0} matching frame(s) and {target}. Check Npcap, adapter selection, port mirroring, APPID/MAC/svID, and publisher state."; + } + + private static string NormalizeIdentity(string? value) + { + var normalized = (value ?? string.Empty).Trim(); + return normalized == "-" ? string.Empty : normalized; + } + + private static string NormalizeMac(string? value) + => Regex.Replace(value ?? string.Empty, "[^0-9A-Fa-f]", string.Empty).ToUpperInvariant(); + + 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 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"; +} \ No newline at end of file diff --git a/SmvViewerWindow.xaml b/SmvViewerWindow.xaml index b3d2748..baeda07 100644 --- a/SmvViewerWindow.xaml +++ b/SmvViewerWindow.xaml @@ -1,12 +1,14 @@ + Icon="Assets/app-icon.ico" + Loaded="Window_Loaded" + Closed="Window_Closed"> @@ -31,7 +33,7 @@ - @@ -42,42 +44,157 @@ + + + + - + + - + + + + + + + + - - - + + + + + + + + + + + + + + +