Skip to content
Merged
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
96 changes: 81 additions & 15 deletions Services/DiagnosticReportBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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") ?? "-"}, " +
Expand Down Expand Up @@ -384,6 +386,41 @@ static string Safe(string value)
builder.AppendLine($" stream list : TRUNCATED • shown={snapshot.Streams.Count}, total={snapshot.StreamCount}");
}

/// <summary>
/// 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.
/// </summary>
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";

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -507,6 +539,39 @@ private static RouteAnalysis AnalyzeRoute(string targetText)
note);
}

/// <summary>
/// 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.
/// </summary>
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
Expand All @@ -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,
Expand Down
143 changes: 143 additions & 0 deletions tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System.Net;
using System.Text;
using ArIED61850Tester.Services;

namespace ARSAS.Tests;

/// <summary>
/// Deterministic IEC-aware decisions: no live NIC, IED, sockets or simulator.
/// Hardware/network routing and physical SOE completeness are separate tests.
/// </summary>
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);
}
}
Loading