diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 981d69b46..844f7476b 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -796,7 +796,10 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot( streamEvent.ChangedValueCount, frame.Pdu.Test, frame.Pdu.NeedsCommissioning, - leaves); + leaves, + frame.Vlan is { } tagged ? tagged.VlanId.ToString(CultureInfo.InvariantCulture) : "Untagged", + frame.Vlan is { } priority ? priority.PriorityCodePoint.ToString(CultureInfo.InvariantCulture) : "—", + rawValueCount); } private static string FirstNonEmpty(params string?[] values) diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index d57a3cfde..61e0a6ba8 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -324,6 +324,8 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, EventText = eventText, EventTone = tone, Publisher = BuildGoosePublisherName(stream), + IedName = string.IsNullOrWhiteSpace(stream.ModelIedName) ? "Unresolved" : stream.ModelIedName, + GooseId = string.IsNullOrWhiteSpace(stream.GoId) ? "Not provided" : stream.GoId, StateSequenceText = $"{stream.StateNumberText} / {stream.SequenceNumberText}", SourceMac = stream.SourceMac, DestinationMac = stream.DestinationMac, diff --git a/MainWindow.IecNaturalSort.cs b/MainWindow.IecNaturalSort.cs new file mode 100644 index 000000000..90226b39d --- /dev/null +++ b/MainWindow.IecNaturalSort.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// Natural IEC sorting is attached to the actual native seven-column grid. +/// The earlier search-module heuristic expected six columns and never ran. +/// This changes only the WPF collection view, not the report/DataSet order. +/// +public partial class MainWindow +{ + private readonly HashSet _iecNaturallySortedGrids = new(); + + private void ExplorerLiveGrid_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not DataGrid grid) return; + if (_iecNaturallySortedGrids.Add(grid)) + { + var descriptor = DependencyPropertyDescriptor.FromProperty( + ItemsControl.ItemsSourceProperty, typeof(DataGrid)); + descriptor?.AddValueChanged(grid, (_, _) => + grid.Dispatcher.BeginInvoke(new Action(() => ApplyExplorerNaturalSort(grid)))); + } + ApplyExplorerNaturalSort(grid); + } + + private static void ApplyExplorerNaturalSort(DataGrid grid) + { + if (grid.ItemsSource is null) return; + if (CollectionViewSource.GetDefaultView(grid.ItemsSource) is not ListCollectionView view) + return; + if (ReferenceEquals(view.CustomSort, IecNaturalLiveMonitorSort.Instance)) + return; + view.SortDescriptions.Clear(); + view.CustomSort = IecNaturalLiveMonitorSort.Instance; + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml index f3acaadf5..2a1a37657 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -662,6 +662,7 @@ string.IsNullOrWhiteSpace(ModelIedName) ? "Not resolved" : ModelIedName; public string GooseIdDisplay => string.IsNullOrWhiteSpace(GoId) ? "Not provided" : GoId; + public string ApplicationIdDecimal + { + get + { + var raw = AppIdText.Trim(); + return raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && + ushort.TryParse(raw.AsSpan(2), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var value) + ? value.ToString(CultureInfo.InvariantCulture) : raw; + } + } + public string SimulationTestText => Test ? "True" : "False"; + public string DataSetEntryCountText => WireDataSetEntryCount.ToString(CultureInfo.InvariantCulture); public string DataSetShortName => string.IsNullOrWhiteSpace(DataSetReference) ? "Not provided" : ShortReference(DataSetReference); public string GoCbRefShortName => string.IsNullOrWhiteSpace(GoCbRef) ? "Not provided" : ShortReference(GoCbRef); public string StateSequenceText => $"{StateNumberText} / {SequenceNumberText}"; @@ -36,6 +49,9 @@ private void RaisePresentationProperties() Raise(nameof(DisplayName)); Raise(nameof(DisplaySecondary)); Raise(nameof(ModelIedDisplay)); + Raise(nameof(ApplicationIdDecimal)); + Raise(nameof(SimulationTestText)); + Raise(nameof(DataSetEntryCountText)); Raise(nameof(GooseIdDisplay)); Raise(nameof(DataSetShortName)); Raise(nameof(GoCbRefShortName)); @@ -78,6 +94,8 @@ public sealed class GooseEventRow : ObservableObject public string DestinationMac { get; init; } = "—"; public string DataSetName { get; init; } = "—"; public string RelativeTime { get; init; } = "—"; + public string IedName { get; init; } = "Unresolved"; + public string GooseId { get; init; } = "Not provided"; public GooseStreamSnapshot? Snapshot { get; init; } public bool IsRecent { get => _isRecent; private set => Set(ref _isRecent, value); } diff --git a/Models/GooseSubscriberModels.cs b/Models/GooseSubscriberModels.cs index 2d3d55ea2..6f2d64b09 100644 --- a/Models/GooseSubscriberModels.cs +++ b/Models/GooseSubscriberModels.cs @@ -104,6 +104,9 @@ public sealed partial class GooseStreamRow : ObservableObject private int _changedValueCount; private bool _test; private bool _needsCommissioning; + private string _vlanId = "Untagged"; + private string _vlanPriority = "—"; + private int _wireDataSetEntryCount; public string StreamKey { get; init; } = string.Empty; public ObservableCollection Leaves { get; } = new(); @@ -130,6 +133,9 @@ public sealed partial class GooseStreamRow : ObservableObject public int ChangedValueCount { get => _changedValueCount; set => Set(ref _changedValueCount, Math.Max(0, value)); } public bool Test { get => _test; set => Set(ref _test, value); } public bool NeedsCommissioning { get => _needsCommissioning; set => Set(ref _needsCommissioning, value); } + public string VlanId { get => _vlanId; private set => Set(ref _vlanId, value); } + public string VlanPriority { get => _vlanPriority; private set => Set(ref _vlanPriority, value); } + public int WireDataSetEntryCount { get => _wireDataSetEntryCount; private set => Set(ref _wireDataSetEntryCount, value); } public string IdentityText => !string.IsNullOrWhiteSpace(GoCbRef) ? GoCbRef : (!string.IsNullOrWhiteSpace(GoId) ? GoId : AppIdText); public string FlagsText => Test && NeedsCommissioning ? "TEST • ndsCom" : Test ? "TEST" : NeedsCommissioning ? "ndsCom" : "Normal"; @@ -144,6 +150,10 @@ public void Apply(GooseStreamSnapshot snapshot) SourceMac = snapshot.SourceMac; DestinationMac = snapshot.DestinationMac; VlanText = snapshot.VlanText; + VlanId = snapshot.VlanId; + VlanPriority = snapshot.VlanPriority; + WireDataSetEntryCount = snapshot.WireDataSetEntryCount == 0 + ? snapshot.Leaves.Count : snapshot.WireDataSetEntryCount; StateNumberText = snapshot.StateNumberText; SequenceNumberText = snapshot.SequenceNumberText; SequenceStatus = snapshot.SequenceStatus; @@ -252,4 +262,7 @@ public sealed record GooseStreamSnapshot( int ChangedValueCount, bool Test, bool NeedsCommissioning, - IReadOnlyList Leaves); + IReadOnlyList Leaves, + string VlanId = "Untagged", + string VlanPriority = "—", + int WireDataSetEntryCount = 0); diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index 51a818f4e..2a5b73c7d 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -113,7 +113,9 @@ public static IReadOnlyList Project(IReadOnlyList(); foreach (var value in wireLeaves) { - if (!IsValue(value)) continue; + if (!IsValue(value) && + !(IsModelBound(value) && value.SignalName.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase))) + continue; var valueRef = Owner(value.SignalReference, out _); var exact = qualities.Where(q => string.Equals(valueRef, Owner(q.SignalReference, out _), StringComparison.OrdinalIgnoreCase)).ToArray(); @@ -136,7 +138,9 @@ public static IReadOnlyList Project(IReadOnlyList IsValue(row) && @@ -144,6 +148,31 @@ public static IReadOnlyList Project(IReadOnlyList + q.DataSetIndex == value.DataSetIndex + 1 && + IsModelBound(q) && + q.SignalName.EndsWith(".q", StringComparison.OrdinalIgnoreCase) && + Owner(q.SignalName, out var qKind) == shortOwner && + qKind == "quality" && valueKind == "value" && + IsReadableQuality(q.Value) && + (string.IsNullOrWhiteSpace(q.FunctionalConstraint) || + string.IsNullOrWhiteSpace(value.FunctionalConstraint) || + q.FunctionalConstraint.Equals(value.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)) && + (CompatibleOwners(valueRef, Owner(q.SignalReference, out _)) || + CompatibleOwners(DoScopedReference(value.SignalReference), + DoScopedReference(q.SignalReference)))).ToArray(); + if (neighbors.Length == 1 && + wireLeaves.Count(row => row.SignalName.Equals(value.SignalName, StringComparison.OrdinalIgnoreCase)) == 1) + candidate = neighbors[0]; + } if (candidate is null || !pairedQualities.Add(candidate.DataSetIndex)) continue; pairs[value.DataSetIndex] = candidate; @@ -166,6 +195,30 @@ public static IReadOnlyList Project(IReadOnlyList= 0 && dot + 4 < key.Length && + key.AsSpan(dot + 1, 3).Equals("ST.".AsSpan(), StringComparison.OrdinalIgnoreCase)) + key = key.Remove(dot + 1, 3); + foreach (var suffix in new[] { ".stVal", ".q" }) + if (key.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + key = key[..^suffix.Length]; + return key; + } + + private static bool IsReadableQuality(string value) => + value.Equals("Good", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Good ·", StringComparison.OrdinalIgnoreCase) || + value.Equals("Invalid", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Invalid ·", StringComparison.OrdinalIgnoreCase) || + value.Equals("Questionable", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Questionable ·", StringComparison.OrdinalIgnoreCase) || + value.Equals("Reserved", StringComparison.OrdinalIgnoreCase); + private static bool IsModelBound(GooseLeafValueSnapshot leaf) => !leaf.BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(leaf.SignalReference); diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index f27e2e488..1c5c708b9 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -354,9 +354,10 @@ - - - + + + + - - - - - + + + + + + @@ -462,24 +464,33 @@ - + - - + + + + - - - - - - - - + + + + + + + + @@ -490,22 +501,39 @@ - + - - + + + + + - - - - - - - - + + + + + + + + + + @@ -516,23 +544,51 @@ - + - - + + + + + + + - - - - - - - - + + + + + + + + + + + + + + diff --git a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs index 48f38b530..ff032c54d 100644 --- a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs +++ b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs @@ -92,6 +92,24 @@ static GooseLeafValueSnapshot Leaf(int n,string name,string reference,string typ Assert.Equal(2,projected[1].DataSetIndex); } + [Fact] + public void SameSclDoScopedReferencePlusAdjacentTypedQuality_PairsWithoutGuessing() + { + static GooseLeafValueSnapshot Leaf(int order,string name,string reference,string type,string value) + => new(order,order-1,name,reference,"ST","DPC",type,value,"",false,"SCL"); + var wire=new[]{ + Leaf(1,"CSWI6.Pos.stVal","BCUGEF650/CSWI6.Pos","Dbpos","Off"), + Leaf(2,"CSWI6.Pos.q","BCUGEF650/CSWI6.Pos","Quality","Good"), + Leaf(3,"CSWI7.Pos.stVal","BCUGEF650/CSWI7.Pos","Dbpos","Intermediate"), + Leaf(4,"CSWI7.Pos.q","BCUGEF650/CSWI7.Pos","Quality","Questionable") + }; + var view=GooseCanonicalLeafProjection.Project(wire); + Assert.Equal(2,view.Count); + Assert.Equal("Good",view[0].Quality); + Assert.Equal("Questionable",view[1].Quality); + Assert.Equal(4,wire.Length); + } + [Fact] public void DifferentExplicitLogicalDevicesCannotTransferQuality() { @@ -106,6 +124,24 @@ static GooseLeafValueSnapshot Leaf(int n,string name,string path,string type,str Assert.Equal("—",projected[0].Quality); } + [Fact] + public void F650WireDataSet_RendersQInQualityColumn_WithoutReorderingRawMembers() + { + static GooseLeafValueSnapshot Leaf(int pos,string name,string reference,string type,string value) + => new(pos,pos-1,name,reference,"ST","DPC",type,value,"",false,"SCL"); + var wire = new[] { + Leaf(1,"CSWI6.Pos.stVal","BCUGEF650/CSWI6.Pos.stVal","Dbpos","Off"), + Leaf(2,"CSWI6.Pos.q","CSWI6.Pos.q","Quality","Good"), + Leaf(3,"CSWI7.Pos.stVal","BCUGEF650/CSWI7.Pos.stVal","Dbpos","Intermediate"), + Leaf(4,"CSWI7.Pos.q","CSWI7.Pos.q","Quality","Good") + }; + var view = GooseCanonicalLeafProjection.Project(wire); + Assert.Equal(4, wire.Length); + Assert.Equal(2, view.Count); + Assert.All(view, leaf => Assert.Equal("Good",leaf.Quality)); + Assert.Equal(new[]{0,2},view.Select(leaf=>leaf.DataSetIndex).ToArray()); + } + [Fact] public void PreviousTypedQualityAndDbpos_AreReadableInChangeSummary() { diff --git a/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs b/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs new file mode 100644 index 000000000..9c1eb35e3 --- /dev/null +++ b/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Xml.Linq; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class GooseNoviceInspectorRegressionTests +{ + [Fact] + public void ApplicationId_IsDerivedFromRealWireValue_NotHardcoded() + { + var row = new GooseStreamRow { AppIdText = "0x002C" }; + Assert.Equal("44", row.ApplicationIdDecimal); + row.AppIdText = "0x0021"; + Assert.Equal("33", row.ApplicationIdDecimal); + } + + [Fact] + public void Inspector_UsesRealWireVlanAndDatasetEntryCount() + { + var row = new GooseStreamRow { StreamKey="G1" }; + var snapshot = new GooseStreamSnapshot( + StreamKey:"G1",AppIdText:"0x002C",GoCbRef:"LD/LLN0$GO$gcb", + GoId:"BCUGE03",DataSetReference:"LD/LLN0.GOOSE1", + SourceMac:"00:11:22:33:44:55",DestinationMac:"01:0C:CD:01:00:33", + VlanText:"VID 0 / PCP 4",StateNumberText:"2",SequenceNumberText:"185", + SequenceStatus:"Retransmission",TimeAllowedToLiveText:"2000 ms", + ConfigurationRevisionText:"1",ModelIedName:"BCUGE",BindingSource:"SCL", + DiagnosticsSummary:"",LastSeenText:"16:00:00.000",PacketCount:14, + ChangedValueCount:0,Test:true,NeedsCommissioning:false, + Leaves:Array.Empty(),VlanId:"0",VlanPriority:"4", + WireDataSetEntryCount:4); + row.Apply(snapshot); + Assert.Equal("44",row.ApplicationIdDecimal); + Assert.Equal("0",row.VlanId); + Assert.Equal("4",row.VlanPriority); + Assert.Equal("2",row.StateNumberText); + Assert.Equal("185",row.SequenceNumberText); + Assert.Equal("True",row.SimulationTestText); + Assert.Equal("4",row.DataSetEntryCountText); + } + + [Fact] + public void ActualGooseView_UsesNoviceMetadataLabelsAndTimelineColumns() + { + var view=Read("Views/GooseSubscriberLiteView.xaml"); + var xml=XDocument.Parse(view); + XNamespace wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + var headers=xml.Descendants(wpf+"DataGridTextColumn") + .Select(x=>(string?)x.Attribute("Header")).ToArray(); + Assert.Contains("IED",headers); + Assert.Contains("GOOSE ID",headers); + var time=xml.Descendants(wpf+"DataGridTextColumn") + .Single(x=>(string?)x.Attribute("Header")=="Time"); + Assert.True(int.Parse((string)time.Attribute("Width")!,CultureInfo.InvariantCulture)>=110); + foreach(var label in new[]{"Application ID","VLAN ID","VLAN priority", + "Time allowed to live","Status number","Sequence number", + "Configuration revision","Simulation test","Number of DataSet entries"}) + Assert.Contains($"Text=\"{label}\"",view); + Assert.Contains("SelectedGooseStream.ApplicationIdDecimal",view); + Assert.Contains("WireDataSetEntryCount",Read("Models/GooseSubscriberModels.cs")); + Assert.Contains("rawValueCount);",Read("MainWindow.GooseSubscriber.cs")); + Assert.Contains("IedName = ",Read("MainWindow.GooseTimeline.cs")); + Assert.Contains("GooseId = ",Read("MainWindow.GooseTimeline.cs")); + } + + private static string Read(string path) + { + DirectoryInfo? d=new(AppContext.BaseDirectory); + while(d!=null) + { + var file=Path.Combine(d.FullName,path); + if(File.Exists(file))return File.ReadAllText(file); + d=d.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs b/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs new file mode 100644 index 000000000..96d10546c --- /dev/null +++ b/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs @@ -0,0 +1,39 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class NativeExplorerNaturalSortRegressionTests +{ + [Fact] + public void RealSevenColumnExplorerGrid_AttachesTheNaturalSortHandler() + { + var xaml=Read("MainWindow.xaml"); + Assert.Contains("ItemsSource=\"{Binding SelectedDevice.Points}\" IsReadOnly=\"True\"",xaml); + Assert.Contains("Loaded=\"ExplorerLiveGrid_Loaded\"",xaml); + var source=Read("MainWindow.IecNaturalSort.cs"); + Assert.Contains("view.CustomSort = IecNaturalLiveMonitorSort.Instance",source); + Assert.Contains("view.SortDescriptions.Clear()",source); + } + + [Fact] + public void CorrectOrderingIncludesDoubleDigitInstances() + { + var source=new[]{"F650/CSWI11.Pos.stVal","F650/CSWI2.Pos.stVal", + "F650/CSWI1.Pos.stVal","F650/CSWI10.Pos.stVal"}; + var sorted=source.OrderBy(x=>x,Comparer.Create(IecNaturalReferenceOrder.Compare)).ToArray(); + Assert.Equal(new[]{"F650/CSWI1.Pos.stVal","F650/CSWI2.Pos.stVal", + "F650/CSWI10.Pos.stVal","F650/CSWI11.Pos.stVal"},sorted); + } + + private static string Read(string path) + { + DirectoryInfo? d=new(AppContext.BaseDirectory); + while(d!=null) + { + var f=Path.Combine(d.FullName,path); + if(File.Exists(f))return File.ReadAllText(f); + d=d.Parent; + } + throw new FileNotFoundException(path); + } +}