From 6519106ef56f00045af1a37a9e2fd676ce472e3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 07:10:41 +0700 Subject: [PATCH 1/9] feat(goose): typed engineering values, quality pairing, immutable frame inspector and automatic routed NIC --- MainWindow.GooseSubscriber.cs | 8 +- MainWindow.GooseTimeline.cs | 20 +++- MainWindow.IedGooseQuickStart.cs | 18 +++- Models/GoosePresentationModels.cs | 7 +- Models/GooseSubscriberModels.cs | 20 +++- Services/GooseTypedValueInterpreter.cs | 97 +++++++++++++++++++ Views/GooseSubscriberLiteView.xaml | 12 ++- .../GooseCanonicalEngineeringTests.cs | 51 ++++++++++ 8 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 Services/GooseTypedValueInterpreter.cs create mode 100644 tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 3a3312e91..64d046c41 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -698,8 +698,10 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot( var bindingSource = decoded?.IsMappedToScl == true ? "SCL" : binding?.Source ?? "Unbound"; + var resolvedCdc = decoded?.IsMappedToScl == true ? decoded.Cdc : definition?.Cdc ?? string.Empty; + var resolvedType = decoded?.IsMappedToScl == true ? decoded.BType : definition?.BType ?? string.Empty; var value = index < rawValueCount - ? decoded?.DisplayValue ?? MmsDataValueRenderer.ToCompactString(frame.Pdu.Values[index], signalReference) + ? GooseTypedValueInterpreter.Render(frame.Pdu.Values[index], resolvedCdc, resolvedType, signalReference) : ""; leaves.Add(new GooseLeafValueSnapshot( @@ -708,8 +710,8 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot( definition?.SignalName ?? BuildSignalName(signalReference, index), signalReference, decoded?.IsMappedToScl == true ? decoded.Fc : definition?.FunctionalConstraint ?? string.Empty, - decoded?.IsMappedToScl == true ? decoded.Cdc : definition?.Cdc ?? string.Empty, - decoded?.IsMappedToScl == true ? decoded.BType : definition?.BType ?? string.Empty, + resolvedCdc, + resolvedType, value, decoded?.PreviousDisplayValue ?? string.Empty, decoded?.IsChanged ?? false, diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 0ed13c6e4..6ce924a73 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -25,6 +25,7 @@ public bool ShowGooseRetransmissions get => _showGooseRetransmissions; set => Set(ref _showGooseRetransmissions, value); } + private DateTimeOffset? _gooseTimelineStart; private readonly Dictionary _lastGooseTimelineTimestamp = new(StringComparer.OrdinalIgnoreCase); private GooseEventRow? _selectedGooseEvent; private bool _goosePresentationInstalled; @@ -40,8 +41,16 @@ public GooseEventRow? SelectedGooseEvent if (!Set(ref _selectedGooseEvent, value)) return; - if (value is not null && _gooseStreamIndex.TryGetValue(value.StreamKey, out var stream)) - SelectedGooseStream = stream; + if (value is not null) + { + // Inspector is immutable for the chosen historical frame. + // Streaming latest publisher state must never mutate this row. + var historical = new GooseStreamRow { StreamKey = value.StreamKey }; + historical.Apply(value.Snapshot); + SelectedGooseStream = historical; + } + else + SelectedGooseStream = null; } } @@ -247,6 +256,7 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, if (_lastGooseTimelineTimestamp.TryGetValue(captured.StreamKey, out var prev)) deltaText = FormatGooseDelta(captured.CaptureTimestamp - prev); _lastGooseTimelineTimestamp[captured.StreamKey] = captured.CaptureTimestamp; + _gooseTimelineStart ??= captured.CaptureTimestamp; return new GooseEventRow { StreamKey = captured.StreamKey, @@ -256,6 +266,11 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, EventTone = tone, Publisher = BuildGoosePublisherName(stream), StateSequenceText = $"{stream.StateNumberText} / {stream.SequenceNumberText}", + SourceMac = stream.SourceMac, + DestinationMac = stream.DestinationMac, + DataSetName = ShortGooseReference(stream.DataSetReference), + RelativeTime = (captured.CaptureTimestamp - _gooseTimelineStart.Value).TotalSeconds.ToString("0.000000", CultureInfo.InvariantCulture), + Snapshot = stream, Summary = valueAnomaly ? "Payload changed without stNum increment — verify publisher." : BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning) }; @@ -357,6 +372,7 @@ private void ResetGooseTimelineUi() { } _lastGooseTimelineTimestamp.Clear(); + _gooseTimelineStart = null; lock (_gooseTimelineGate) _lastGooseTimelineSignature.Clear(); _nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue; GooseEvents.Clear(); diff --git a/MainWindow.IedGooseQuickStart.cs b/MainWindow.IedGooseQuickStart.cs index 0b8fc1c7b..38a793eb2 100644 --- a/MainWindow.IedGooseQuickStart.cs +++ b/MainWindow.IedGooseQuickStart.cs @@ -124,13 +124,23 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device) if (GooseAdapters.Count == 0) RefreshGooseAdapters(); - // Never assume the unicast MMS route is the GOOSE L2 network. The - // Npcap adapter must be explicitly selected before capture begins. + // MMS route is an initial NIC recommendation, not proof of the + // station GOOSE VLAN. Only auto-start when one non-loopback Npcap + // adapter uniquely matches the actual Windows-routed interface. + var proposed = ResolveGooseAdapterForIed(device, out var route); + if (proposed is not null && !LooksLikeLoopback(proposed)) + { + SelectedGooseAdapter = proposed; + GooseStatusText = $"Listening on suggested adapter {proposed.DisplayText}. Change adapter if GOOSE uses a separate port/VLAN."; + SetStatus($"GOOSE: starting on {proposed.DisplayText}."); + StartGooseSubscriber_Click(this, new RoutedEventArgs()); + return; + } SelectedGooseAdapter = null; _pendingIedGooseAutoStart = device; GooseStatusText = GooseAdapters.Count == 0 - ? "No Ethernet capture adapter found. Check Npcap and refresh the adapter list." - : $"Select the station network adapter for {device.Name}. GOOSE capture starts automatically after selection."; + ? "No Ethernet capture adapter found. Check Npcap and refresh adapters." + : $"Choose the GOOSE station adapter for {device.Name}; capture begins on selection. {route}"; SetStatus($"GOOSE: select network adapter for {device.Name}."); })); } diff --git a/Models/GoosePresentationModels.cs b/Models/GoosePresentationModels.cs index 5e9d05607..582131bad 100644 --- a/Models/GoosePresentationModels.cs +++ b/Models/GoosePresentationModels.cs @@ -28,7 +28,7 @@ public string DisplaySecondary public string StateSequenceText => $"{StateNumberText} / {SequenceNumberText}"; public string StateSequenceCompactText => $"st {StateNumberText} • sq {SequenceNumberText}"; public string ModelStateText => BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase) - ? "Raw / unbound" + ? "Signal names unavailable · open matching publisher SCL/CID" : BindingSource; private void RaisePresentationProperties() @@ -72,6 +72,11 @@ public sealed class GooseEventRow : ObservableObject public required string Publisher { get; init; } public required string StateSequenceText { get; init; } public required string Summary { get; init; } + public required string SourceMac { get; init; } + public required string DestinationMac { get; init; } + public required string DataSetName { get; init; } + public required string RelativeTime { get; init; } + public required GooseStreamSnapshot Snapshot { 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); diff --git a/Models/GooseSubscriberModels.cs b/Models/GooseSubscriberModels.cs index 54e670a0f..2d3d55ea2 100644 --- a/Models/GooseSubscriberModels.cs +++ b/Models/GooseSubscriberModels.cs @@ -30,6 +30,7 @@ public sealed class GooseLeafValueRow : ObservableObject private string _value = "-"; private string _previousValue = string.Empty; private string _bindingSource = "Unbound"; + private string _quality = "—"; private bool _isChanged; private bool _isHighlighted; private DateTimeOffset _highlightUntilUtc; @@ -44,6 +45,7 @@ public sealed class GooseLeafValueRow : ObservableObject public string Value { get => _value; set => Set(ref _value, string.IsNullOrWhiteSpace(value) ? "-" : value); } 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 string Quality { get => _quality; set => Set(ref _quality, 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))); @@ -61,6 +63,7 @@ public void Apply(GooseLeafValueSnapshot snapshot) PreviousValue = snapshot.PreviousValue; Value = snapshot.Value; BindingSource = snapshot.BindingSource; + Quality = snapshot.Quality; IsChanged = snapshot.IsChanged || changedOnWire; if (IsChanged) { @@ -104,6 +107,7 @@ public sealed partial class GooseStreamRow : ObservableObject public string StreamKey { get; init; } = string.Empty; public ObservableCollection Leaves { get; } = new(); + public ObservableCollection EngineeringLeaves { get; } = new(); public string AppIdText { get => _appIdText; set => Set(ref _appIdText, value ?? "-"); } public string GoCbRef { get => _goCbRef; set => Set(ref _goCbRef, value ?? string.Empty); } @@ -154,12 +158,25 @@ public void Apply(GooseStreamSnapshot snapshot) Test = snapshot.Test; NeedsCommissioning = snapshot.NeedsCommissioning; ApplyLeaves(snapshot.Leaves); + ApplyEngineeringLeaves(ArIED61850Tester.Services.GooseCanonicalLeafProjection.Project(snapshot.Leaves)); Raise(nameof(IdentityText)); Raise(nameof(FlagsText)); Raise(nameof(HealthText)); RaisePresentationProperties(); } + private void ApplyEngineeringLeaves(IReadOnlyList snapshots) + { + while (EngineeringLeaves.Count > snapshots.Count) + EngineeringLeaves.RemoveAt(EngineeringLeaves.Count - 1); + for (var index = 0; index < snapshots.Count; index++) + { + if (index >= EngineeringLeaves.Count) + EngineeringLeaves.Add(new GooseLeafValueRow { Order = snapshots[index].Order, DataSetIndex = snapshots[index].DataSetIndex }); + EngineeringLeaves[index].Apply(snapshots[index]); + } + } + private void ApplyLeaves(IReadOnlyList snapshots) { while (Leaves.Count > snapshots.Count) @@ -210,7 +227,8 @@ public sealed record GooseLeafValueSnapshot( string Value, string PreviousValue, bool IsChanged, - string BindingSource); + string BindingSource, + string Quality = "—"); public sealed record GooseStreamSnapshot( string StreamKey, diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs new file mode 100644 index 000000000..1ce3b088a --- /dev/null +++ b/Services/GooseTypedValueInterpreter.cs @@ -0,0 +1,97 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Interprets MMS GOOSE payloads using their actual wire types and verified +/// FCDA metadata. Never assumes an arbitrary bit string is an IEC quality. +/// The raw value remains accessible separately for diagnostics. +/// +public static class GooseTypedValueInterpreter +{ + public static string Render(MmsDataValue value, string? cdc, string? bType, string? reference) + { + if (value.Kind != MmsDataKind.BitString) + return MmsDataValueRenderer.ToCompactString(value, reference); + var raw = value.RawValue.Span; + if (raw.Length == 2 && raw[0] == 6 && + (string.Equals(bType, "Dbpos", StringComparison.OrdinalIgnoreCase) || + string.Equals(cdc, "DPC", StringComparison.OrdinalIgnoreCase))) + return ((raw[1] >> 6) & 3) switch + { + 0 => "Intermediate", + 1 => "Off", + 2 => "On", + _ => "Invalid" + }; + if (IsQuality(reference, bType)) + return RenderQuality(value); + return MmsDataValueRenderer.ToCompactString(value, reference); + } + + public static bool IsQuality(string? reference, string? bType) + => string.Equals(bType, "Quality", StringComparison.OrdinalIgnoreCase) || + (reference?.EndsWith(".q", StringComparison.OrdinalIgnoreCase) ?? false); + + public static string RenderQuality(MmsDataValue value) + { + if (value.Kind != MmsDataKind.BitString) + return MmsDataValueRenderer.ToCompactString(value); + var raw = value.RawValue.Span; + // IEC 61850 Quality: exactly 13 significant bits, encoded MSB-first. + if (raw.Length != 3 || raw[0] != 3) + return MmsDataValueRenderer.ToCompactString(value); + var flags = (raw[1] << 8) | raw[2]; + var validity = (flags >> 14) & 3; + var pieces = new List(12) + { + validity switch { 0 => "Good", 1 => "Invalid", 2 => "Reserved", _ => "Questionable" } + }; + if ((flags & 0x2000) != 0) pieces.Add("Overflow"); + if ((flags & 0x1000) != 0) pieces.Add("Out of range"); + if ((flags & 0x0800) != 0) pieces.Add("Bad reference"); + if ((flags & 0x0400) != 0) pieces.Add("Oscillatory"); + if ((flags & 0x0200) != 0) pieces.Add("Failure"); + if ((flags & 0x0100) != 0) pieces.Add("Old data"); + if ((flags & 0x0080) != 0) pieces.Add("Inconsistent"); + if ((flags & 0x0040) != 0) pieces.Add("Inaccurate"); + if ((flags & 0x0020) != 0) pieces.Add("Substituted"); + if ((flags & 0x0010) != 0) pieces.Add("Test"); + if ((flags & 0x0008) != 0) pieces.Add("Operator blocked"); + return string.Join(" · ", pieces); + } +} + +/// Pairs only exact, independently identified q and primary FCDA references. +public static class GooseCanonicalLeafProjection +{ + public static IReadOnlyList Project(IReadOnlyList wireLeaves) + { + var quality = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var leaf in wireLeaves) + { + if (!string.IsNullOrWhiteSpace(leaf.SignalReference) && + leaf.SignalReference.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) + quality[leaf.SignalReference[..^2]] = leaf.Value; + } + var result = new List(wireLeaves.Count); + foreach (var leaf in wireLeaves) + { + var reference = leaf.SignalReference; + if (!string.IsNullOrWhiteSpace(reference) && + reference.EndsWith(".q", StringComparison.OrdinalIgnoreCase) && + quality.ContainsKey(reference[..^2]) && + wireLeaves.Any(candidate => candidate.SignalReference.StartsWith(reference[..^2] + ".", StringComparison.OrdinalIgnoreCase) + && !candidate.SignalReference.EndsWith(".q", StringComparison.OrdinalIgnoreCase))) + continue; + var suffix = reference.LastIndexOf('.'); + var objectRef = suffix > 0 ? reference[..suffix] : string.Empty; + result.Add(leaf with + { + Quality = objectRef.Length > 0 && quality.TryGetValue(objectRef, out var q) ? q : "—" + }); + } + return result; + } +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index f3f5baa2a..19a503ddd 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -348,7 +348,7 @@ - + @@ -373,8 +373,10 @@ - - + + + + @@ -556,7 +558,7 @@ - + - diff --git a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs new file mode 100644 index 000000000..7eed26f05 --- /dev/null +++ b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs @@ -0,0 +1,51 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Services; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; +public sealed class GooseCanonicalEngineeringTests +{ + [Fact] + public void TypedQuality_BecomesReadableWithoutRawBitString() + { + var valid = MmsDataValue.BitString(3, new byte[] { 0, 0 }); + Assert.Equal("Good", GooseTypedValueInterpreter.Render(valid, "", "Quality", "C264/CSWI1.Pos.q")); + var old = MmsDataValue.BitString(3, new byte[] { 0x01, 0x00 }); + Assert.Contains("Old data", GooseTypedValueInterpreter.RenderQuality(old)); + } + + [Theory] + [InlineData(0, "Intermediate")] + [InlineData(1, "Off")] + [InlineData(2, "On")] + [InlineData(3, "Invalid")] + public void TypedDbpos_OnlyFromProvenDpc(int state, string expected) + { + var value = MmsDataValue.BitString(6, new[] { (byte)(state << 6) }); + Assert.Equal(expected, GooseTypedValueInterpreter.Render(value, "DPC", "Dbpos", "CSWI1.Pos.stVal")); + } + + [Fact] + public void UnknownBitString_RemainsRawInsteadOfInventingEngineeringType() + { + var value = MmsDataValue.BitString(3, new byte[] { 0, 0 }); + Assert.Contains("bits(", GooseTypedValueInterpreter.Render(value, "", "", "")); + } + + [Fact] + public void CanonicalQ_OnlyJoinsItsMatchingQualifiedStatus() + { + static GooseLeafValueSnapshot Leaf(int i,string reference,string value) => + new(i+1,i,reference,reference,"ST","DPC","",value,"",false,"SCL"); + var leaves = new[] { + Leaf(0,"C264/CSWI1.Pos.stVal","Off"), + Leaf(1,"C264/CSWI1.Pos.q","Good"), + Leaf(2,"C264/CSWI2.Pos.stVal","On"), + Leaf(3,"C264/CSWI2.Pos.q","Invalid") + }; + var projected = GooseCanonicalLeafProjection.Project(leaves); + Assert.Equal(2,projected.Count); + Assert.Equal("Good",projected[0].Quality); + Assert.Equal("Invalid",projected[1].Quality); + } +} From 2ed149110faf475a33580e575e70149cd1eeeb74 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 07:14:13 +0700 Subject: [PATCH 2/9] refine(goose): meaningful signal deltas, linear canonical quality join, six primary columns --- MainWindow.GooseTimeline.cs | 32 +++++++------ Services/GooseTypedValueInterpreter.cs | 46 +++++++++++++++---- Views/GooseSubscriberLiteView.xaml | 2 +- ...eEngineeringPresentationRegressionTests.cs | 31 +++++++++++++ 4 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 tests/ARSAS.Tests/GooseEngineeringPresentationRegressionTests.cs diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 6ce924a73..4b6203079 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -247,9 +247,10 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, var valueAnomaly = decision.PayloadChanged && !decision.StateChanged; var changed = decision.PayloadChanged || decision.StateChanged; var eventText = decision.IsNew ? "New" : - valueAnomaly ? "Value changed" : + valueAnomaly ? "Unexpected change" : + changed ? "Changed" : hasNewWarning || decision.NewSequenceAnomaly ? "Warning" : - changed ? "State change" : FriendlySequenceStatus(stream.SequenceStatus); + FriendlySequenceStatus(stream.SequenceStatus); var tone = valueAnomaly || hasNewWarning || decision.NewSequenceAnomaly ? "Warning" : changed ? "Change" : "Info"; var deltaText = "-"; @@ -271,8 +272,9 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, DataSetName = ShortGooseReference(stream.DataSetReference), RelativeTime = (captured.CaptureTimestamp - _gooseTimelineStart.Value).TotalSeconds.ToString("0.000000", CultureInfo.InvariantCulture), Snapshot = stream, - Summary = valueAnomaly ? "Payload changed without stNum increment — verify publisher." : - BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning) + Summary = valueAnomaly + ? "Unexpected stNum: " + BuildGooseEventSummary(stream, false, hasNewWarning) + : BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning) }; } @@ -289,18 +291,21 @@ private static string BuildGoosePublisherName(GooseStreamSnapshot stream) private static string BuildGooseEventSummary(GooseStreamSnapshot stream, bool isNew, bool hasDiagnostics) { - if (hasDiagnostics) - return ShortenGooseText(stream.DiagnosticsSummary, 150); - + // The details column must lead with actual signal deltas, not a + // persistent Test=true banner or a raw sequence diagnostic. var changed = stream.Leaves .Where(leaf => leaf.IsChanged) .Take(2) .Select(leaf => { - var current = ShortenGooseText(GooseEngineeringValueFormatter.Format(leaf.Value), 30); - return IsGenericGooseLeafName(leaf.SignalName) - ? current - : $"{ShortenGooseText(leaf.SignalName, 22)}: {current}"; + var name = IsGenericGooseLeafName(leaf.SignalName) + ? $"Value {leaf.Order}" + : ShortenGooseText(leaf.SignalName, 22); + var before = GooseTypedValueInterpreter.RenderPrevious(leaf.PreviousValue, leaf.Cdc, leaf.BType); + var after = ShortenGooseText(leaf.Value, 26); + return string.IsNullOrWhiteSpace(before) + ? $"{name}: {after}" + : $"{name}: {ShortenGooseText(before, 20)} → {after}"; }) .ToArray(); if (changed.Length > 0) @@ -312,8 +317,9 @@ private static string BuildGooseEventSummary(GooseStreamSnapshot stream, bool is } if (isNew) - return $"Publisher detected • {stream.Leaves.Count:N0} DataSet value(s)"; - + return $"Publisher detected · {stream.Leaves.Count:N0} DataSet entries"; + if (hasDiagnostics) + return ShortenGooseText(stream.DiagnosticsSummary, 150); return FriendlySequenceStatus(stream.SequenceStatus); } diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index 1ce3b088a..b789f6779 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -30,6 +30,28 @@ public static string Render(MmsDataValue value, string? cdc, string? bType, stri return MmsDataValueRenderer.ToCompactString(value, reference); } + /// Legacy previous values have only MMS renderer text, not the old typed node. + public static string RenderPrevious(string? previous, string? cdc, string? bType) + { + if (string.IsNullOrWhiteSpace(previous)) + return string.Empty; + if ((string.Equals(cdc, "DPC", StringComparison.OrdinalIgnoreCase) || + string.Equals(bType, "Dbpos", StringComparison.OrdinalIgnoreCase)) && + previous.StartsWith("bits(", StringComparison.OrdinalIgnoreCase)) + { + var content = previous.AsSpan(5); + var comma = content.IndexOf(','); + if (comma == 2 && byte.TryParse(content[..comma], System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var bits) && + previous.Contains("unused=6", StringComparison.OrdinalIgnoreCase)) + return ((bits >> 6) & 3) switch + { + 0 => "Intermediate", 1 => "Off", 2 => "On", _ => "Invalid" + }; + } + return GooseEngineeringValueFormatter.Format(previous); + } + public static bool IsQuality(string? reference, string? bType) => string.Equals(bType, "Quality", StringComparison.OrdinalIgnoreCase) || (reference?.EndsWith(".q", StringComparison.OrdinalIgnoreCase) ?? false); @@ -69,11 +91,20 @@ public static class GooseCanonicalLeafProjection public static IReadOnlyList Project(IReadOnlyList wireLeaves) { var quality = new Dictionary(StringComparer.OrdinalIgnoreCase); + var valueOwners = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var leaf in wireLeaves) { - if (!string.IsNullOrWhiteSpace(leaf.SignalReference) && - leaf.SignalReference.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) - quality[leaf.SignalReference[..^2]] = leaf.Value; + var path = leaf.SignalReference; + if (string.IsNullOrWhiteSpace(path)) continue; + if (path.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) + quality[path[..^2]] = leaf.Value; + else if (path.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase)) + { + var owner = path.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase) + ? path[..^6] : path[..^6]; + valueOwners.Add(owner); + } } var result = new List(wireLeaves.Count); foreach (var leaf in wireLeaves) @@ -81,12 +112,11 @@ public static IReadOnlyList Project(IReadOnlyList candidate.SignalReference.StartsWith(reference[..^2] + ".", StringComparison.OrdinalIgnoreCase) - && !candidate.SignalReference.EndsWith(".q", StringComparison.OrdinalIgnoreCase))) + valueOwners.Contains(reference[..^2])) continue; - var suffix = reference.LastIndexOf('.'); - var objectRef = suffix > 0 ? reference[..suffix] : string.Empty; + var objectRef = reference.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase) + ? reference[..^6] : reference.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) + ? reference[..^6] : string.Empty; result.Add(leaf with { Quality = objectRef.Length > 0 && quality.TryGetValue(objectRef, out var q) ? q : "—" diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 19a503ddd..39c837efc 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -349,7 +349,7 @@ - + Date: Sat, 10 Oct 2026 07:15:45 +0700 Subject: [PATCH 3/9] fix(goose): adapt IReadOnlyList MMS bytes and preserve demo message initialization --- MainWindow.GooseTimeline.cs | 11 ++++++++--- Models/GoosePresentationModels.cs | 12 +++++++----- Services/GooseTypedValueInterpreter.cs | 8 ++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 4b6203079..e49e9f9b9 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -45,9 +45,14 @@ public GooseEventRow? SelectedGooseEvent { // Inspector is immutable for the chosen historical frame. // Streaming latest publisher state must never mutate this row. - var historical = new GooseStreamRow { StreamKey = value.StreamKey }; - historical.Apply(value.Snapshot); - SelectedGooseStream = historical; + if (value.Snapshot is { } snapshot) + { + var historical = new GooseStreamRow { StreamKey = value.StreamKey }; + historical.Apply(snapshot); + SelectedGooseStream = historical; + } + else if (_gooseStreamIndex.TryGetValue(value.StreamKey, out var simulated)) + SelectedGooseStream = simulated; } else SelectedGooseStream = null; diff --git a/Models/GoosePresentationModels.cs b/Models/GoosePresentationModels.cs index 582131bad..49803e66a 100644 --- a/Models/GoosePresentationModels.cs +++ b/Models/GoosePresentationModels.cs @@ -72,11 +72,13 @@ public sealed class GooseEventRow : ObservableObject public required string Publisher { get; init; } public required string StateSequenceText { get; init; } public required string Summary { get; init; } - public required string SourceMac { get; init; } - public required string DestinationMac { get; init; } - public required string DataSetName { get; init; } - public required string RelativeTime { get; init; } - public required GooseStreamSnapshot Snapshot { get; init; } + // Real wire events populate all six-column fields plus an immutable frame. + // Simulated demo events may omit the frame; they keep their explicit values. + public string SourceMac { get; init; } = "—"; + public string DestinationMac { get; init; } = "—"; + public string DataSetName { get; init; } = "—"; + public string RelativeTime { get; init; } = "—"; + public GooseStreamSnapshot? Snapshot { 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); diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index b789f6779..46186e358 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -14,8 +14,8 @@ public static string Render(MmsDataValue value, string? cdc, string? bType, stri { if (value.Kind != MmsDataKind.BitString) return MmsDataValueRenderer.ToCompactString(value, reference); - var raw = value.RawValue.Span; - if (raw.Length == 2 && raw[0] == 6 && + var raw = value.RawValue; + if (raw.Count == 2 && raw[0] == 6 && (string.Equals(bType, "Dbpos", StringComparison.OrdinalIgnoreCase) || string.Equals(cdc, "DPC", StringComparison.OrdinalIgnoreCase))) return ((raw[1] >> 6) & 3) switch @@ -60,9 +60,9 @@ public static string RenderQuality(MmsDataValue value) { if (value.Kind != MmsDataKind.BitString) return MmsDataValueRenderer.ToCompactString(value); - var raw = value.RawValue.Span; + var raw = value.RawValue; // IEC 61850 Quality: exactly 13 significant bits, encoded MSB-first. - if (raw.Length != 3 || raw[0] != 3) + if (raw.Count != 3 || raw[0] != 3) return MmsDataValueRenderer.ToCompactString(value); var flags = (raw[1] << 8) | raw[2]; var validity = (flags >> 14) & 3; From 1346206159abd9211817b34a17903587bdbaa94c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 07:16:46 +0700 Subject: [PATCH 4/9] fix(goose): fail-closed canonical stream binding and one-click trusted publisher SCL import --- MainWindow.GooseSubscriber.cs | 55 ++++++++++++++++++--------- MainWindow.GooseTimeline.cs | 18 ++++++++- Views/GooseSubscriberLiteView.xaml | 21 +++++++++- Views/GooseSubscriberLiteView.xaml.cs | 12 ++++++ 4 files changed, 85 insertions(+), 21 deletions(-) diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 64d046c41..ab469fb00 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -833,32 +833,51 @@ public GooseBindingCatalog(SclDocument? sclDocument, IReadOnlyList 0 && !string.Equals(goCb, frameGoCb, StringComparison.OrdinalIgnoreCase)) + continue; + if (dataSet.Length > 0 && !string.Equals(dataSet, frameDataSet, StringComparison.OrdinalIgnoreCase)) + continue; + + var goCbExact = goCb.Length > 0; + var dataSetExact = dataSet.Length > 0; + if (!goCbExact && !dataSetExact) continue; + var score = (goCbExact ? 120 : 0) + (dataSetExact ? 90 : 0) + + (binding.AppId.HasValue ? 50 : 0) + + (binding.ConfigurationRevision.HasValue ? 10 : 0) + + (binding.Source.Equals("SCL", StringComparison.OrdinalIgnoreCase) ? 5 : 0); if (score > bestScore) { + winner = binding; bestScore = score; - best = candidate; + ambiguous = false; } + else if (score == bestScore) + ambiguous = true; } - return bestScore >= 90 ? best : null; + return ambiguous ? null : winner; } } } diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index e49e9f9b9..15bd07dd3 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -61,7 +61,9 @@ public GooseEventRow? SelectedGooseEvent public string GoosePublisherCountText => $"{GooseStreams.Count:N0}"; public string GooseEventCountText => $"{GooseEvents.Count:N0}"; - public string GooseSelectedLeafCountText => $"{SelectedGooseStream?.Leaves.Count ?? 0:N0} values"; + public string GooseSelectedLeafCountText => SelectedGooseStream is null + ? "0 signals" + : $"{SelectedGooseStream.EngineeringLeaves.Count:N0} signals · {SelectedGooseStream.Leaves.Count:N0} entries"; public Visibility GooseNoEventsVisibility => GooseEvents.Count == 0 ? Visibility.Visible : Visibility.Collapsed; [ModuleInitializer] @@ -83,6 +85,10 @@ internal static void RegisterGoosePresentationIntegration() typeof(MainWindow), GooseSubscriberLiteView.AdapterConfirmedEvent, new RoutedEventHandler(OnGooseAdapterConfirmed)); + EventManager.RegisterClassHandler( + typeof(MainWindow), + GooseSubscriberLiteView.ImportPublisherModelRequestedEvent, + new RoutedEventHandler(OnImportGoosePublisherModelRequested)); EventManager.RegisterClassHandler( typeof(MainWindow), GooseSubscriberLiteView.StartRequestedEvent, @@ -152,6 +158,16 @@ private static void OnGooseAdapterConfirmed(object sender, RoutedEventArgs args) args.Handled = true; } + private static void OnImportGoosePublisherModelRequested(object sender, RoutedEventArgs args) + { + if (sender is not MainWindow window) + return; + // Uses the existing, engine-validated SCL import workflow. Never infer + // publisher IP from GOOSE source MAC or synthesize missing FCDA identities. + window.OpenScl_Click(window, new RoutedEventArgs()); + args.Handled = true; + } + private static void OnStartGooseRequested(object sender, RoutedEventArgs args) { if (sender is not MainWindow window) diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 39c837efc..7d6504031 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -410,7 +410,7 @@ - @@ -549,9 +549,26 @@ + + + + + +