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);
+ }
+
+
}