diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs
index 9d687470a..336332cdd 100644
--- a/Services/DiagnosticReportBuilder.cs
+++ b/Services/DiagnosticReportBuilder.cs
@@ -12,7 +12,7 @@ namespace ArIED61850Tester.Services;
internal static class DiagnosticReportBuilder
{
private sealed record TcpProbeResult(string DeviceId, string Endpoint, string Result, string Detail);
- private sealed record AdapterIpv4(string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength);
+ private sealed record AdapterIpv4(string InterfaceId, string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength);
private sealed record RouteAnalysis(string Source, string MatchingAdapters, string AdapterMatrix, string Note);
public static string BuildEmergency(
@@ -323,6 +323,8 @@ static string Safe(string value)
$"sqWithGIreason={stream.SequenceWithGiReason}, " +
$"sqWithBufOvfl={stream.SequenceWithOverflow}; " +
"EntryID difference/identical is not an event-loss or harmless-replay conclusion.");
+ if (stream.Buffered && stream.Findings > 0)
+ builder.AppendLine($" BRCB context : {ExplainBrcbContinuity(stream)}");
if (stream.AnomalousFrames > 0)
builder.AppendLine($" RCB follow-up : receivedAfterLastFinding={stream.FramesAfterLastFinding}, " +
$"firstFrameUtc={stream.FirstFrameReceivedAtUtc?.ToString("O") ?? "-"}, " +
@@ -384,6 +386,41 @@ static string Safe(string value)
builder.AppendLine($" stream list : TRUNCATED • shown={snapshot.Streams.Count}, total={snapshot.StreamCount}");
}
+ ///
+ /// IEC 61850-aware interpretation of already observed RCB metadata:
+ /// temporal context and GI reason are correlations, never proof of lost
+ /// buffered entries, a server reset, or that a sequence gap is harmless.
+ /// No extra MMS traffic, raw event retention or timer.
+ ///
+ internal static string ExplainBrcbContinuity(Iec61850ReportContinuityStreamSnapshot stream)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ var withoutProvenGi = Math.Max(0, stream.SequenceDiscontinuities - stream.SequenceWithGiReason);
+ var context = $"sequenceDiscontinuities={stream.SequenceDiscontinuities}, " +
+ $"GI-tagged={stream.SequenceWithGiReason}, withoutConfirmedGI={withoutProvenGi}, " +
+ $"BufOvflAdvertised={stream.Overflow}";
+
+ if (stream.ActivationReturnedAtUtc is { } activation &&
+ stream.LastAnomalyReceivedAtUtc is { } anomaly &&
+ anomaly >= activation)
+ {
+ var delta = anomaly - activation;
+ var elapsed = delta.TotalMilliseconds;
+ if (double.IsFinite(elapsed))
+ {
+ context += $", lastAnomalyAfterStartMs={elapsed:0}";
+ if (delta >= TimeSpan.FromSeconds(5))
+ context += "; late recurrence observed beyond 5s of RCB activation (NOT startup-only)";
+ }
+ }
+ else
+ {
+ context += "; timing=UNKNOWN (insufficient RCB lifecycle evidence)";
+ }
+
+ return context + "; received-frame order only, not TCP wire order, event-loss or GI causality proof";
+ }
+
private static string Verdict(bool? value) =>
value is true ? "YES" : value is false ? "NO" : "UNKNOWN";
@@ -457,8 +494,10 @@ private static RouteAnalysis AnalyzeRoute(string targetText)
return new RouteAnalysis("unavailable", "unavailable", "unavailable", "Route analysis currently supports IPv4 endpoints.");
var adapters = GetActiveIpv4Adapters();
- var matching = adapters
+ var matchingAddresses = adapters
.Where(item => IsInSameSubnet(item.Address, target, item.PrefixLength))
+ .ToArray();
+ var matching = matchingAddresses
.Select(item => $"{item.Name}={item.Address}/{item.PrefixLength}")
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -486,19 +525,12 @@ private static RouteAnalysis AnalyzeRoute(string targetText)
source = $"unavailable ({ex.GetType().Name}: {ex.Message})";
}
- string note;
- if (matching.Length == 0)
- {
- note = $"No active IPv4 adapter is in the same subnet as {target}. Check the PC adapter address, prefix length, VLAN, and static route.";
- }
- else if (matching.Length == 1)
- {
- note = $"{target} is directly reachable only through {matching[0]}. Ensure the IED is physically connected to that network.";
- }
- else
- {
- note = $"Multiple adapters match {target}; Windows route priority can select the wrong interface. Disable unused adapters or configure interface metrics/static routes.";
- }
+ // Do not confuse multiple IP aliases on ONE KM-TEST interface with
+ // competing network adapters. Exact local IP means same-host endpoint;
+ // neither condition warrants telling operators to disable interfaces.
+ var note = ExplainIpv4Route(target,
+ matchingAddresses.Select(item => (item.InterfaceId, item.Name)).ToArray(),
+ adapters.Any(item => item.Address.Equals(target)));
return new RouteAnalysis(
source,
@@ -507,6 +539,39 @@ private static RouteAnalysis AnalyzeRoute(string targetText)
note);
}
+ ///
+ /// Pure, testable interface-aware route advice. The interface ID, not the
+ /// number of matching IPv4 addresses, defines routing ambiguity. This is
+ /// descriptive only: the actual OS routing table remains authoritative.
+ ///
+ internal static string ExplainIpv4Route(
+ IPAddress target,
+ IReadOnlyList<(string InterfaceId, string AdapterName)> matchedAddresses,
+ bool targetAssignedLocally)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(matchedAddresses);
+ var distinctInterfaces = matchedAddresses
+ .GroupBy(item => item.InterfaceId, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First().AdapterName)
+ .ToArray();
+ if (targetAssignedLocally)
+ return $"Target {target} is assigned to this PC on " +
+ $"{(distinctInterfaces.Length == 0 ? "a local interface" : string.Join(", ", distinctInterfaces))}; " +
+ "this can be a same-host IED simulator. Do not infer a competing NIC from IPv4 aliases. " +
+ "Check the local MMS listener and actual OS route if connection fails.";
+ if (distinctInterfaces.Length == 0)
+ return $"No directly matching IPv4 subnet for {target}; a gateway or static route can still provide connectivity. " +
+ "Check the Windows routing table before drawing a connectivity conclusion.";
+ if (distinctInterfaces.Length == 1)
+ return $"One network interface ({distinctInterfaces[0]}) matches {target}, across " +
+ $"{matchedAddresses.Count} IPv4 address(es). IP aliases are not competing adapters; " +
+ "confirm actual OS routing and physical/virtual IED connection if needed.";
+ return $"{distinctInterfaces.Length} distinct network interfaces match {target} " +
+ $"({string.Join(", ", distinctInterfaces)}); Windows interface metrics/routing table may select " +
+ "a different path. Check the OS route before changing adapter configuration.";
+ }
+
private static AdapterIpv4[] GetActiveIpv4Adapters()
{
try
@@ -517,6 +582,7 @@ private static AdapterIpv4[] GetActiveIpv4Adapters()
.SelectMany(adapter => adapter.GetIPProperties().UnicastAddresses
.Where(address => address.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(address => new AdapterIpv4(
+ adapter.Id,
adapter.Name,
adapter.NetworkInterfaceType,
address.Address,
diff --git a/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs b/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs
new file mode 100644
index 000000000..2c256ae77
--- /dev/null
+++ b/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs
@@ -0,0 +1,143 @@
+using System.Net;
+using System.Text;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+///
+/// Deterministic IEC-aware decisions: no live NIC, IED, sockets or simulator.
+/// Hardware/network routing and physical SOE completeness are separate tests.
+///
+public sealed class ContextAwareIec61850DiagnosisTests
+{
+ private static readonly IPAddress Bcuge = IPAddress.Parse("192.16.1.33");
+
+ [Fact]
+ public void SamePhysicalInterfaceWithFiveAliases_DoesNotClaimFiveCompetingNics()
+ {
+ var addresses = Enumerable.Range(1, 5)
+ .Select(_ => (InterfaceId: "KM-TEST-GUID", AdapterName: "KM_Test")).ToArray();
+ var description = DiagnosticReportBuilder.ExplainIpv4Route(Bcuge, addresses, false);
+ Assert.Contains("One network interface (KM_Test)", description);
+ Assert.Contains("5 IPv4 address(es)", description);
+ Assert.Contains("not competing adapters", description);
+ Assert.DoesNotContain("distinct network interfaces", description);
+ }
+
+ [Fact]
+ public void DeviceIpIsLocalSimulatorEndpoint_DoesNotRecommendDisablingInterfaces()
+ {
+ var description = DiagnosticReportBuilder.ExplainIpv4Route(
+ Bcuge,
+ new[] { (InterfaceId: "KM-TEST-GUID", AdapterName: "KM_Test") },
+ targetAssignedLocally: true);
+ Assert.Contains("assigned to this PC", description);
+ Assert.Contains("same-host IED simulator", description);
+ Assert.DoesNotContain("Disable", description, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("physically connected", description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void DifferentInterfacesWithSameFriendlyName_AreStillARealRoutingAmbiguity()
+ {
+ var description = DiagnosticReportBuilder.ExplainIpv4Route(
+ Bcuge,
+ new[]
+ {
+ (InterfaceId: "guid-1", AdapterName: "Ethernet"),
+ (InterfaceId: "guid-2", AdapterName: "Ethernet"),
+ (InterfaceId: "GUID-1", AdapterName: "Ethernet")
+ },
+ targetAssignedLocally: false);
+ Assert.Contains("2 distinct network interfaces", description);
+ Assert.Contains("routing table", description);
+ }
+
+ [Fact]
+ public void NoDirectSubnetMatch_DoesNotClaimGatewayRoutingImpossible()
+ {
+ var description = DiagnosticReportBuilder.ExplainIpv4Route(
+ Bcuge, Array.Empty<(string InterfaceId, string AdapterName)>(), false);
+ Assert.Contains("gateway or static route can still provide connectivity", description);
+ Assert.DoesNotContain("unreachable", description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void BrcbLateRecurrence_IsNotWronglyExplainedAsStartupOnlyOrGiReset()
+ {
+ var activation = new DateTimeOffset(2026, 10, 12, 0, 26, 4, TimeSpan.Zero);
+ var stream = new Iec61850ReportContinuityStreamSnapshot
+ {
+ Rcb = "BCUGEF650/LLN0.BR.BRB_CSWI_GW010101",
+ Buffered = true,
+ Frames = 55,
+ Sequenced = 55,
+ Findings = 22,
+ SequenceDiscontinuities = 21,
+ SequenceWithGiReason = 1,
+ Overflow = 1,
+ ActivationReturnedAtUtc = activation,
+ LastAnomalyReceivedAtUtc = activation.AddSeconds(48.575)
+ };
+ var output = DiagnosticReportBuilder.ExplainBrcbContinuity(stream);
+ Assert.Contains("GI-tagged=1, withoutConfirmedGI=20", output);
+ Assert.Contains("BufOvflAdvertised=1", output);
+ Assert.Contains("lastAnomalyAfterStartMs=48575", output);
+ Assert.Contains("late recurrence observed beyond 5s", output);
+ Assert.Contains("not TCP wire order", output);
+ Assert.DoesNotContain("events were lost", output);
+
+ // Must also surface in ordinary user diagnostic, not only in helpers.
+ var builder = new StringBuilder();
+ DiagnosticReportBuilder.AppendReportContinuity(builder,
+ new Iec61850ReportContinuitySnapshot
+ {
+ Frames = 55, Sequenced = 55, Findings = 22, Streams = new[] { stream }
+ });
+ Assert.Contains("BRCB context :", builder.ToString());
+ Assert.Contains("NOT startup-only", builder.ToString());
+ }
+
+ [Fact]
+ public void StartupOnlyObservedFrames_AreNotPromotedToCausalCertainty()
+ {
+ var activation = DateTimeOffset.UtcNow;
+ var stream = new Iec61850ReportContinuityStreamSnapshot
+ {
+ Buffered = true,
+ Findings = 2,
+ SequenceDiscontinuities = 2,
+ SequenceWithGiReason = 0,
+ ActivationReturnedAtUtc = activation,
+ LastAnomalyReceivedAtUtc = activation.AddMilliseconds(250)
+ };
+ var description = DiagnosticReportBuilder.ExplainBrcbContinuity(stream);
+ Assert.Contains("lastAnomalyAfterStartMs=250", description);
+ Assert.DoesNotContain("late recurrence", description);
+ Assert.Contains("not TCP wire order", description);
+ Assert.Contains("withoutConfirmedGI=2", description);
+ }
+
+ [Fact]
+ public void UnknownOrReversedTimestamps_DoNotFabricateLateRecurrence()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var noTiming = DiagnosticReportBuilder.ExplainBrcbContinuity(
+ new Iec61850ReportContinuityStreamSnapshot
+ {
+ Buffered = true,
+ SequenceDiscontinuities = 1
+ });
+ Assert.Contains("timing=UNKNOWN", noTiming);
+ var reversed = DiagnosticReportBuilder.ExplainBrcbContinuity(
+ new Iec61850ReportContinuityStreamSnapshot
+ {
+ Buffered = true,
+ SequenceDiscontinuities = 1,
+ ActivationReturnedAtUtc = now,
+ LastAnomalyReceivedAtUtc = now.AddSeconds(-1)
+ });
+ Assert.Contains("timing=UNKNOWN", reversed);
+ Assert.DoesNotContain("late recurrence", reversed);
+ }
+}