Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 100 additions & 4 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -978,6 +1006,74 @@ void Put(string name, string value, string authority)
}
}

/// <summary>
/// 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.
/// </summary>
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<string>
{
"",
"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();
Expand Down
4 changes: 4 additions & 0 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
19 changes: 19 additions & 0 deletions Models/IedBrowserGooseInspection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ public static class IedBrowserGooseInspection
return values;
}

/// <summary>
/// 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.
/// </summary>
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(
Expand Down
66 changes: 66 additions & 0 deletions tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}


}
Loading