diff --git a/SmvViewerWindow.xaml.cs b/SmvViewerWindow.xaml.cs index 5778b613b..6113a54f8 100644 --- a/SmvViewerWindow.xaml.cs +++ b/SmvViewerWindow.xaml.cs @@ -32,7 +32,7 @@ public partial class SmvViewerWindow : Window, INotifyPropertyChanged private CancellationTokenSource? _captureCancellation; private SmvSnapshotResult? _snapshot; private string _statusText = string.Empty; - private string _captureStatusText = "Select an adapter and stream, then capture a bounded two-cycle snapshot."; + private string _captureStatusText = "Select an Ethernet adapter, then capture the first received SV stream or select a configured stream."; private string _snapshotBadgeText = "No snapshot"; private string _snapshotSummaryText = "No Sampled Values snapshot has been captured yet."; private string _snapshotEvidenceText = "The snapshot will report stream identity, payload shape, sample-counter continuity and a static raw waveform."; @@ -52,10 +52,11 @@ public SmvViewerWindow(Iec61850MonitorDevice device) foreach (var row in BuildRows(device)) Streams.Add(row); - SelectedStream = Streams.FirstOrDefault(); - StatusText = Streams.Count == 0 - ? "No Sampled Value control block is configured in the opened SCL model or discovered from this IED." - : $"{Streams.Count:N0} Sampled Value stream definition(s) are available. Snapshot capture is passive and does not write to the IED."; + SelectedStream = Streams[0]; + var configured = Streams.Count - 1; + StatusText = configured == 0 + ? "Ready for passive SV capture on Ethernet. Select the correct Npcap adapter and Capture to discover the first stream." + : $"Capture any received SV stream or choose one of {configured:N0} configured stream(s). No network frame is transmitted."; DataContext = this; } @@ -127,9 +128,11 @@ public SmvStreamRow? SelectedStream } public string SelectedStreamDetail => SelectedStream == null - ? "Select one stream before starting the bounded capture." - : $"{SelectedStream.Source} • {SelectedStream.ControlReference} • DataSet {SelectedStream.DataSetReference} • " + - $"APPID {SelectedStream.AppId} • {SelectedStream.MemberCount} member(s)."; + ? "Select an SV stream or the auto-discovery option." + : SelectedStream.Source == "Live Ethernet (auto)" + ? "Automatically capture the first decodable SV stream arriving on the selected Ethernet adapter. No SCL, APPID, MAC or svID filter is required." + : $"{SelectedStream.Source} • {SelectedStream.ControlReference} • DataSet {SelectedStream.DataSetReference} • " + + $"APPID {SelectedStream.AppId} • {SelectedStream.MemberCount} member(s)."; private void Window_Loaded(object sender, RoutedEventArgs e) { @@ -556,6 +559,25 @@ private void Raise([CallerMemberName] string? propertyName = null) public sealed class SmvStreamRow { + /// + /// Passive first-stream discovery. The capture request normalizes "-" + /// into unrestricted APPID, MAC, svID and timebase hints. + /// + public static SmvStreamRow AnyReceived() => new() + { + Source = "Live Ethernet (auto)", + ControlReference = "First received SV stream", + StreamId = "-", + DataSetReference = "-", + AppId = "-", + DestinationMac = "-", + Vlan = "-", + SampleRate = "-", + SampleMode = "-", + NumberOfAsdu = "-", + MemberCount = 0 + }; + public string Source { get; init; } = string.Empty; public string LiveKey { get; init; } = string.Empty; public string ControlReference { get; init; } = string.Empty; diff --git a/tests/ARSAS.Tests/ArstackM3hSvInteropTests.cs b/tests/ARSAS.Tests/ArstackM3hSvInteropTests.cs new file mode 100644 index 000000000..48893dc9f --- /dev/null +++ b/tests/ARSAS.Tests/ArstackM3hSvInteropTests.cs @@ -0,0 +1,80 @@ +using System.Buffers.Binary; +using System.Text; +using AR.Iec61850.SampledValues; +using Xunit; + +namespace ARSAS.Tests; + +/// +/// Checks that ARSAS's pinned native SV parser accepts the ARStack M3.H +/// reference layout, including priority-tagged VLAN ID 0. +/// This is a wire-decoder test, not evidence of physical Ethernet delivery. +/// +public sealed class ArstackM3hSvInteropTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SnapshotEngine_RecognizesM3h4I4VFrame(bool vlanPriorityTag) + { + var sampleData = new byte[64]; + var analogValues = new[] { 2500, -1250, -1250, 0, 330000, -165000, -165000, 0 }; + for (var i = 0; i < analogValues.Length; i++) + BinaryPrimitives.WriteInt32BigEndian(sampleData.AsSpan(i * 8, 4), analogValues[i]); + + var asdu = Tlv(0x30, + Tlv(0x80, Encoding.ASCII.GetBytes("MU01_SV1")), + Tlv(0x82, U16(3999)), + Tlv(0x83, new byte[] { 0x00, 0x00, 0x00, 0x01 }), + Tlv(0x85, new byte[] { 0x02 }), + Tlv(0x87, sampleData), + Tlv(0x89, new byte[] { 0x02, 0x11, 0x22, 0xFF, 0xFE, 0x33, 0x44, 0x55 })); + + var apdu = Tlv(0x60, + Tlv(0x80, new byte[] { 0x01 }), + Tlv(0xA2, asdu)); + var payload = Join(U16(0x4000), U16((ushort)(8 + apdu.Length)), + U16(0), U16(0), apdu); + var ethernet = vlanPriorityTag + ? Join(DestinationMac, SourceMac, U16(0x8100), U16(0x8000), U16(0x88BA), payload) + : Join(DestinationMac, SourceMac, U16(0x88BA), payload); + + Assert.True(SampledValuesFrameParser.TryParseEthernetFrame(ethernet, out var frame)); + Assert.Equal((ushort)0x4000, frame.AppId); + Assert.Equal(vlanPriorityTag, frame.Vlan is not null); + if (vlanPriorityTag) + Assert.Equal((ushort)0, frame.Vlan?.VlanId); + var decoded = Assert.Single(frame.Pdu.Asdus); + Assert.Equal("MU01_SV1", decoded.SvId); + Assert.Equal((ushort)3999, decoded.SampleCount); + Assert.Equal(64, decoded.SamplePayload.Length); + } + + private static readonly byte[] DestinationMac = { 0x01, 0x0C, 0xCD, 0x04, 0x00, 0x01 }; + private static readonly byte[] SourceMac = { 0x02, 0x11, 0x22, 0x33, 0x44, 0x55 }; + + private static byte[] U16(ushort number) => new[] { (byte)(number >> 8), (byte)number }; + + private static byte[] Tlv(byte tag, params byte[][] parts) + { + var content = Join(parts); + if (content.Length < 128) + return Join(new byte[] { tag, (byte)content.Length }, content); + if (content.Length < 256) + return Join(new byte[] { tag, 0x81, (byte)content.Length }, content); + return Join(new byte[] { tag, 0x82, (byte)(content.Length >> 8), (byte)content.Length }, content); + } + + private static byte[] Join(params byte[][] arrays) + { + var size = arrays.Sum(a => a.Length); + var result = new byte[size]; + var index = 0; + foreach (var array in arrays) + { + array.CopyTo(result, index); + index += array.Length; + } + return result; + } +} diff --git a/tests/ARSAS.Tests/SmvFirstReceivedStreamTests.cs b/tests/ARSAS.Tests/SmvFirstReceivedStreamTests.cs new file mode 100644 index 000000000..2afa619f4 --- /dev/null +++ b/tests/ARSAS.Tests/SmvFirstReceivedStreamTests.cs @@ -0,0 +1,20 @@ +using ArIED61850Tester; + +namespace ARSAS.Tests; + +public sealed class SmvFirstReceivedStreamTests +{ + [Fact] + public void NewOperatorCanReceiveAnySvWithoutMatchingSclIdentity() + { + var row = SmvStreamRow.AnyReceived(); + + Assert.Equal("Live Ethernet (auto)", row.Source); + Assert.Equal("-", row.AppId); + Assert.Equal("-", row.StreamId); + Assert.Equal("-", row.DestinationMac); + Assert.Equal("-", row.SampleRate); + Assert.Equal("-", row.SampleMode); + Assert.Equal(0, row.MemberCount); + } +}