diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 7d9ad19b6..1c9648bf3 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -562,7 +562,16 @@ private void RefreshIedBrowserVisibleValues() var version = device.LiveValues.Version; if (_iedBrowserLastLiveVersion == version && ReferenceEquals(_iedBrowserLastCfCanonicalModel, device.LiveCanonicalModel)) + { + // Passive GOOSE TTL expiry and received RCB continuity are not + // version increments of the MMS value ledger. Project the existing + // evidence on the UI flush tick even when no MMS value changed. + if (_iedBrowserRenderedPage?.Kind == "GoCB") + UpdateIedBrowserGooseDetails(); + else if (_iedBrowserRenderedPage?.Kind == "RCB") + UpdateIedBrowserRcbReportDetails(); return; + } _iedBrowserLastLiveVersion = version; _iedBrowserLastCfCanonicalModel = device.LiveCanonicalModel; // No MMS call occurs during navigation or refresh. Exact CF/DC @@ -908,13 +917,29 @@ void Put(string name, string value, string authority) // Filter raw bounded cache by exact GoCB before building display leaves. // Two different publisher streams claiming the same identity are rejected. + var nowUtc = DateTimeOffset.UtcNow; + var controlReference = IedBrowserGooseInspection.Canonical(identity.GoCbRef); + // Existing bounded subscriber cache; never open another capture or + // promote any GOOSE sample to the MMS/Report value ledger. Filter by + // *wire* TTL BEFORE choosing the two unique identity candidates. var found = IsGooseCapturing ? _latestGooseFrames.Values.Where(frame => IedBrowserGooseInspection.Canonical(frame.Frame.Pdu.GoCbRef) == - IedBrowserGooseInspection.Canonical(identity.GoCbRef)).Take(2) + controlReference && + IedBrowserGooseInspection.IsFrameCurrent( + frame.CaptureTimestamp, + frame.Frame.Pdu.TimeAllowedToLiveMilliseconds, nowUtc)) + .Take(2) .Select(frame => (Raw: frame, Display: BuildGooseStreamSnapshot(frame, _gooseBindingCatalog))).ToArray() : Array.Empty<(GooseSubscriberFrameSnapshot Raw, GooseStreamSnapshot Display)>(); + var expiredSameControl = IsGooseCapturing && found.Length == 0 && + _latestGooseFrames.Values.Any(frame => + IedBrowserGooseInspection.Canonical(frame.Frame.Pdu.GoCbRef) == + controlReference && + !IedBrowserGooseInspection.IsFrameCurrent( + frame.CaptureTimestamp, + frame.Frame.Pdu.TimeAllowedToLiveMilliseconds, nowUtc)); var frameView = IedBrowserGooseInspection.Select(identity, found.Select(x => x.Display)); var received = frameView is null ? null : found .FirstOrDefault(x => ReferenceEquals(frameView, x.Display)).Raw; @@ -965,9 +990,12 @@ void Put(string name, string value, string authority) } else { - Put("Capture status", IsGooseCapturing - ? "No uniquely verified matching frame" : "Subscriber not capturing", - "Passive capture; not an MMS GoCB read"); + Put("Capture status", !IsGooseCapturing + ? "Subscriber not capturing" + : expiredSameControl + ? "GOOSE frame expired · no current live value" + : "No uniquely verified matching current frame", + "Passive capture · exact identity and received TTL required; not an MMS GoCB read"); foreach (var row in _iedBrowserDetails.Rows) { if (row.GooseSlot < 0 || row.ObservationSource != "GOOSE frame") continue; @@ -978,6 +1006,74 @@ void Put(string name, string value, string authority) } } + /// + /// Capture a bounded, read-only diagnostic from the subscriber's existing + /// last-frame index. Exact Browser identity and wire-TTL tests use the + /// same projection as the GoCB page. Never subscribe, read MMS, decode + /// frames again from Ethernet or create a shadow stream cache. + /// + private string CaptureIedBrowserGooseEvidence() + { + if (_iedBrowserRenderedPage?.Kind != "GoCB" || + _iedBrowserGooseExpected is not { } expected) + return ""; + + var now = DateTimeOffset.UtcNow; + var key = IedBrowserGooseInspection.Canonical(expected.GoCbRef); + var observed = _latestGooseFrames.Values + .Where(frame => IedBrowserGooseInspection.Canonical( + frame.Frame.Pdu.GoCbRef) == key) + .OrderByDescending(frame => frame.CaptureTimestamp) + .Take(8).ToArray(); + var lines = new List + { + "", + "GOOSE BROWSER ACCEPTANCE EVIDENCE (passive, non-MMS)", + "--------------------------------------------------", + $"Selected GoCB : {key}", + $"Expected DataSet : {expected.DataSetReference}", + $"Expected GoID : {expected.GoId}", + $"Expected AppID : {(expected.AppId.HasValue ? $"0x{expected.AppId.Value:X4}" : "unspecified")}", + $"Expected confRev : {expected.ConfRev?.ToString() ?? "unspecified"}", + $"Capture running : {IsGooseCapturing}", + $"Selected adapter : {SelectedGooseAdapter?.DisplayText ?? "none"}", + $"Exact GoCB sample : {observed.Length} bounded stream(s); at most 8 shown", + $"Member directory : {_iedBrowserGooseDirectory?.Members.Count.ToString() ?? "unavailable"}" + }; + + foreach (var raw in observed) + { + var fresh = IedBrowserGooseInspection.IsFrameCurrent( + raw.CaptureTimestamp, raw.Frame.Pdu.TimeAllowedToLiveMilliseconds, now); + try + { + var display = BuildGooseStreamSnapshot(raw, _gooseBindingCatalog); + var identity = ReferenceEquals( + IedBrowserGooseInspection.Select(expected, [display]), display); + var members = identity && _iedBrowserGooseDirectory is not null && + IedBrowserGooseInspection.VerifiedMembers( + display, _iedBrowserGooseDirectory) is not null; + var reason = !IsGooseCapturing ? "subscriber-stopped" : + !fresh ? "TTL-expired-or-invalid" : + !identity ? "identity-or-model-binding-mismatch" : + !members ? "ordered-member-directory-not-verified" : "verified-current"; + lines.Add($" {raw.CaptureTimestamp:O} | {reason} | " + + $"TAL={raw.Frame.Pdu.TimeAllowedToLiveMilliseconds}ms | " + + $"DataSet={raw.Frame.Pdu.DataSetReference} | GoID={raw.Frame.Pdu.GoId} | " + + $"confRev={raw.Frame.Pdu.ConfigurationRevision} | " + + $"wireEntries={raw.Frame.Pdu.Values.Count}"); + } + catch (Exception ex) + { + lines.Add($" {raw.CaptureTimestamp:O} | projection-error=" + + ex.GetType().Name + " (no value accepted)"); + } + } + if (observed.Length == 0) + lines.Add(" No passive frame found for selected exact GoCB reference."); + return string.Join(Environment.NewLine, lines) + Environment.NewLine; + } + private void CancelIedBrowserRead() { _iedBrowserReadOperation?.Cancel(); diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 0db1cbac0..55c484a35 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -2935,12 +2935,16 @@ private async void CopyDiagnostics_Click(object sender, RoutedEventArgs e) UiFlushTimer_Tick(null, EventArgs.Empty); devices = Devices.ToArray(); logs = Logs.ToArray(); + // Freeze the passive Browser evidence before any asynchronous + // diagnostic probe; no network request is made for GOOSE here. + var browserGooseEvidence = CaptureIedBrowserGooseEvidence(); var report = await DiagnosticReportBuilder.BuildAsync( devices, logs, SelectedDevice, _applicationCancellation.Token); + report += browserGooseEvidence; Clipboard.SetText(report, TextDataFormat.UnicodeText); SetStatus($"Diagnostic report copied ({report.Length:N0} characters). Paste it into the support conversation."); AddLog("INFO", "Diagnostics", "Support diagnostic report copied to clipboard."); diff --git a/Models/IedBrowserGooseInspection.cs b/Models/IedBrowserGooseInspection.cs index f3178dd64..0bbbc5a77 100644 --- a/Models/IedBrowserGooseInspection.cs +++ b/Models/IedBrowserGooseInspection.cs @@ -37,6 +37,25 @@ public static class IedBrowserGooseInspection return values; } + /// + /// A passive frame is current only within its *received* IEC 61850 + /// timeAllowedToLive. The subscriber owns wire decoding and sequence + /// supervision; this is a stateless UI admission rule. Invalid/zero TTL, + /// future timestamps and expired frames may be shown as historical + /// evidence, never as current Browser values. + /// + public static bool IsFrameCurrent( + DateTimeOffset capturedUtc, long timeAllowedToLiveMilliseconds, + DateTimeOffset nowUtc) + { + if (timeAllowedToLiveMilliseconds <= 0 || capturedUtc > nowUtc) + return false; + // No int-sized millisecond conversion: preserve the exact unsigned + // PDU TTL range in long arithmetic, without wraparound. + return (nowUtc - capturedUtc).TotalMilliseconds < + timeAllowedToLiveMilliseconds; + } + public static string Canonical(string? value) => (value ?? "").Trim().Replace('$', '.'); public static GooseStreamSnapshot? Select( diff --git a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs index feccae4fd..6a7985b31 100644 --- a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs +++ b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs @@ -84,4 +84,70 @@ public void WireDataSetValuesOnlyIfWholeOrderedMemberIdentityMatches() Members = [first, first] }; Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, ambiguous)); } + [Theory] + [InlineData(0, 0, false)] + [InlineData(-1, 1000, false)] + [InlineData(0, 1000, true)] + [InlineData(999, 1000, true)] + [InlineData(1000, 1000, false)] + [InlineData(1001, 1000, false)] + public void BrowserOnlyAdmitsGooseFramesInsideReceivedTimeAllowedToLive( + int ageMilliseconds, long ttlMilliseconds, bool current) + { + var now = new DateTimeOffset(2026, 10, 16, 12, 0, 0, TimeSpan.Zero); + var sample = now.AddMilliseconds(-ageMilliseconds); + Assert.Equal(current, IedBrowserGooseInspection.IsFrameCurrent( + sample, ttlMilliseconds, now)); + } + + [Fact] + public void HugeTtlAndClockSkewDoNotOverflowOrCreateFutureEvidence() + { + var now = new DateTimeOffset(2026, 10, 16, 12, 0, 0, TimeSpan.Zero); + Assert.True(IedBrowserGooseInspection.IsFrameCurrent( + now.AddDays(-1), (long)uint.MaxValue, now)); + Assert.False(IedBrowserGooseInspection.IsFrameCurrent( + now.AddMilliseconds(1), 20000, now)); + Assert.False(IedBrowserGooseInspection.IsFrameCurrent( + now, -1, now)); + } + + [Fact] + public void BrowserRechecksGooseTtlOnUiTickWithoutReadingMms() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null && + !File.Exists(Path.Combine(directory.FullName, "MainWindow.IedBrowser.cs"))) + directory = directory.Parent; + Assert.NotNull(directory); + var source = File.ReadAllText(Path.Combine(directory!.FullName, + "MainWindow.IedBrowser.cs")); + Assert.Contains("IedBrowserGooseInspection.IsFrameCurrent(", source); + Assert.Contains("GOOSE frame expired · no current live value", source); + Assert.Contains("if (_iedBrowserRenderedPage?.Kind == \"GoCB\")", source); + Assert.Contains("UpdateIedBrowserGooseDetails();", source); + } + + + [Fact] + public void CopyDiagnosticsReusesOnlyExistingSubscriberFrameIndex() + { + DirectoryInfo? folder = new(AppContext.BaseDirectory); + while (folder is not null && + !File.Exists(Path.Combine(folder.FullName, "MainWindow.xaml.cs"))) + folder = folder.Parent; + Assert.NotNull(folder); + var main = File.ReadAllText(Path.Combine(folder!.FullName, "MainWindow.xaml.cs")); + var browser = File.ReadAllText(Path.Combine(folder.FullName, "MainWindow.IedBrowser.cs")); + Assert.Contains("CaptureIedBrowserGooseEvidence()", main); + Assert.Contains("GOOSE BROWSER ACCEPTANCE EVIDENCE (passive, non-MMS)", browser); + Assert.Contains("_latestGooseFrames.Values", browser); + Assert.Contains("TTL-expired-or-invalid", browser); + Assert.Contains("identity-or-model-binding-mismatch", browser); + Assert.Contains("ordered-member-directory-not-verified", browser); + Assert.Contains("var browserGooseEvidence = CaptureIedBrowserGooseEvidence();", main); + Assert.Contains("report += browserGooseEvidence;", main); + } + + }