Skip to content
175 changes: 161 additions & 14 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,21 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e)
}

importedSources.Add((sourcePath, document.SourceSha256));
offlineCount += document.Ieds.Count(item => item.CanBrowseOffline);
endpointCount += document.Ieds.Count(item => !item.RequiresEndpointBinding);
var workspaces = SelectPrimarySclWorkspaces(document);
offlineCount += workspaces.Count(item => item.CanBrowseOffline);
endpointCount += workspaces.Count(item => !item.RequiresEndpointBinding);

foreach (var workspace in document.Ieds)
if (workspaces.Count < document.Ieds.Count)
{
var device = Devices.FirstOrDefault(item =>
item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) &&
item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) &&
item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase));
AddLog(
"INFO",
"SCL",
$"{sourceName}: collapsed {document.Ieds.Count} AccessPoint workspace(s) into {workspaces.Count} logical IED card(s); one Engineering card is owned per IEDName.");
}

foreach (var workspace in workspaces)
{
var device = FindReusableSclDevice(document, workspace);

if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring))
{
Expand All @@ -230,6 +236,8 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e)
}

ApplySclWorkspaceToDevice(device, document, workspace, signals);
RestoreKnownSclEndpointIfAvailable(device, workspace);
RemoveRedundantOfflineSclCards(device, document.SourceSha256, workspace.IedName);
firstImported ??= device;
importedDevices.Add(device);
}
Expand Down Expand Up @@ -377,12 +385,17 @@ private void ApplySclWorkspaceToDevice(
device.SclIedName = workspace.IedName;
device.SclAccessPointName = workspace.AccessPointName;
device.HasDiscoveryCache = signals.Count > 0;
device.Status = workspace.RequiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready";

// The SCL may omit a direct MMS address while this exact IEDName already has a
// previously proven endpoint in the current workspace. Do not erase that runtime
// identity merely because the design file is offline-only.
var requiresEndpointBinding = string.IsNullOrWhiteSpace(device.IpAddress);
device.Status = requiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready";
device.Detail = allowDynamicReporting
? (workspace.RequiresEndpointBinding
? (requiresEndpointBinding
? "LD/LN/DO/DA are available offline. Static report coverage is incomplete; after signal selection and endpoint binding, ARSAS will create an association-scoped dynamic DataSet and use a safe free RCB before polling fallback."
: "LD/LN/DO/DA were loaded offline. Static report coverage is incomplete; ARSAS will use static coverage where available and create an association-scoped dynamic DataSet for uncovered selected signals before polling fallback.")
: (workspace.RequiresEndpointBinding
: (requiresEndpointBinding
? "LD/LN/DO/DA are available offline. Press Play to bind an MMS endpoint; no discovery traffic was sent while opening the file."
: "LD/LN/DO/DA were loaded offline. Play performs a fast MMS association; Re-scan performs full design-versus-live verification.");
device.AcquisitionMode = allowDynamicReporting
Expand All @@ -401,6 +414,109 @@ private void ApplySclWorkspaceToDevice(
ScheduleGooseBindingRefreshFromWorkspace();
}

private IReadOnlyList<SclIedWorkspace> SelectPrimarySclWorkspaces(SclWorkspaceDocument document)
{
return document.Ieds
.GroupBy(workspace => workspace.IedName, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(workspace => workspace.PreferredEndpoint?.HasUsableAddress == true)
.ThenByDescending(workspace => document.EngineeringProfile.AccessPoints.Any(accessPoint =>
accessPoint.IedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) &&
accessPoint.Name.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase) &&
accessPoint.HasServer))
.ThenByDescending(workspace => workspace.ReportControls.Count)
.ThenByDescending(workspace => workspace.DataSets.Count)
.ThenByDescending(workspace => workspace.DesignModel.Coverage.LogicalDeviceCount)
.ThenBy(workspace => workspace.AccessPointName, StringComparer.OrdinalIgnoreCase)
.First())
.OrderBy(workspace => workspace.IedName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}

private Iec61850MonitorDevice? FindReusableSclDevice(
SclWorkspaceDocument document,
SclIedWorkspace workspace)
{
var exactSource = Devices
.Where(item =>
item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) &&
item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.IsConnected || item.IsMonitoring)
.ThenByDescending(item => !string.IsNullOrWhiteSpace(item.IpAddress))
.FirstOrDefault();
if (exactSource != null)
return exactSource;

var sclEndpoint = workspace.PreferredEndpoint;
return Devices
.Where(item => item.Name.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase))
.Where(item =>
sclEndpoint?.HasUsableAddress != true ||
string.IsNullOrWhiteSpace(item.IpAddress) ||
(item.IpAddress.Equals(sclEndpoint.IpAddress, StringComparison.OrdinalIgnoreCase) &&
item.Port == sclEndpoint.Port))
.OrderByDescending(item => item.IsConnected || item.IsMonitoring)
.ThenByDescending(item => !string.IsNullOrWhiteSpace(item.IpAddress))
.ThenByDescending(item => item.HasDiscoveryCache)
.FirstOrDefault();
}

private void RestoreKnownSclEndpointIfAvailable(
Iec61850MonitorDevice device,
SclIedWorkspace workspace)
{
if (!string.IsNullOrWhiteSpace(device.IpAddress) ||
workspace.PreferredEndpoint?.HasUsableAddress == true)
{
return;
}

if (!UserPreferenceStore.TryLoadSuccessfulEndpointForIed(
workspace.IedName,
out var ipAddress,
out var port))
{
return;
}

device.IpAddress = ipAddress;
device.Port = port;
device.Status = "SCL model ready • remembered endpoint";
device.Detail =
$"The SCL does not declare a direct MMS IP for this AccessPoint. ARSAS restored {ipAddress}:{port} from the most recent successful session for the exact IEDName '{workspace.IedName}'. Play will validate it with a fresh MMS association before any live operation.";
device.RefreshComputed();
AddLog(
"INFO",
device.Name,
$"SCL has no direct MMS endpoint; restored previously successful endpoint {ipAddress}:{port} for exact IEDName {workspace.IedName}. Live use still requires a fresh association.");
}

private void RemoveRedundantOfflineSclCards(
Iec61850MonitorDevice keeper,
string sourceSha256,
string iedName)
{
var redundant = Devices
.Where(item =>
!ReferenceEquals(item, keeper) &&
!item.IsConnected &&
!item.IsMonitoring &&
!item.IsBusy &&
item.SclSourceSha256.Equals(sourceSha256, StringComparison.OrdinalIgnoreCase) &&
item.SclIedName.Equals(iedName, StringComparison.OrdinalIgnoreCase))
.ToArray();

foreach (var item in redundant)
{
DetachSignalHandlers(item.Signals);
Devices.Remove(item);
AddLog(
"INFO",
"SCL",
$"Removed redundant offline AccessPoint card {item.SclIedName}/{item.SclAccessPointName}; logical IED ownership remains on one Engineering card.");
}
}

private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace)
{
var coverage = workspace.DesignModel.Coverage;
Expand Down Expand Up @@ -2342,6 +2458,9 @@ private async void CopyDiagnostics_Click(object sender, RoutedEventArgs e)
{
var button = sender as Button;
var previousContent = button?.Content;
Iec61850MonitorDevice[] devices = Array.Empty<Iec61850MonitorDevice>();
DiagnosticEntry[] logs = Array.Empty<DiagnosticEntry>();

try
{
if (button != null)
Expand All @@ -2353,8 +2472,8 @@ private async void CopyDiagnostics_Click(object sender, RoutedEventArgs e)
// Include any protocol entries that are still waiting for the normal
// 100 ms UI batch before taking an immutable support snapshot.
UiFlushTimer_Tick(null, EventArgs.Empty);
var devices = Devices.ToArray();
var logs = Logs.ToArray();
devices = Devices.ToArray();
logs = Logs.ToArray();
var report = await DiagnosticReportBuilder.BuildAsync(
devices,
logs,
Expand All @@ -2371,8 +2490,36 @@ private async void CopyDiagnostics_Click(object sender, RoutedEventArgs e)
}
catch (Exception ex)
{
AddLog("ERROR", "Diagnostics", $"Copy Diagnostic failed: {ex.GetType().Name}: {ex.Message}");
SetStatus("Copy Diagnostic failed. The error is recorded in Diagnostics.");
// Support evidence must remain available precisely when workspace state is
// broken. Fall back to a probe-free report instead of turning diagnostics
// into a second failure that hides the original defect.
try
{
if (devices.Length == 0)
devices = Devices.ToArray();
if (logs.Length == 0)
logs = Logs.ToArray();

var fallback = DiagnosticReportBuilder.BuildEmergency(
devices,
logs,
SelectedDevice,
ex);
Clipboard.SetText(fallback, TextDataFormat.UnicodeText);
AddLog(
"WARN",
"Diagnostics",
$"Normal diagnostic collection degraded ({ex.GetType().Name}: {ex.Message}); emergency support report copied instead.");
SetStatus($"Emergency diagnostic report copied ({fallback.Length:N0} characters). Paste it into the support conversation.");
}
catch (Exception fallbackError)
{
AddLog(
"ERROR",
"Diagnostics",
$"Copy Diagnostic failed after emergency fallback: {fallbackError.GetType().Name}: {fallbackError.Message}; original={ex.GetType().Name}: {ex.Message}");
SetStatus("Copy Diagnostic could not access the Windows clipboard. The collection error remains recorded in Diagnostics.");
}
}
finally
{
Expand Down
74 changes: 66 additions & 8 deletions Services/DiagnosticReportBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,60 @@ namespace ArIED61850Tester.Services;

internal static class DiagnosticReportBuilder
{
private sealed record TcpProbeResult(string Endpoint, string Result, string Detail);
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 RouteAnalysis(string Source, string MatchingAdapters, string AdapterMatrix, string Note);

public static string BuildEmergency(
IReadOnlyCollection<Iec61850MonitorDevice> devices,
IReadOnlyCollection<DiagnosticEntry> logs,
Iec61850MonitorDevice? selectedDevice,
Exception collectionFailure)
{
ArgumentNullException.ThrowIfNull(collectionFailure);

var appAssembly = Assembly.GetEntryAssembly() ?? typeof(DiagnosticReportBuilder).Assembly;
var engineAssembly = typeof(AR.Iec61850.Mms.MmsClientSession).Assembly;
var builder = new StringBuilder(16 * 1024);
builder.AppendLine("ARSAS Emergency Diagnostic Report");
builder.AppendLine("Diagnostic collection degraded, but support evidence was preserved.");
builder.AppendLine(new string('=', 72));
builder.AppendLine($"Generated local : {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz}");
builder.AppendLine($"App version : {GetAssemblyVersion(appAssembly)}");
builder.AppendLine($"Engine version : {GetAssemblyVersion(engineAssembly)}");
builder.AppendLine($"Collection error: {collectionFailure.GetType().Name}: {collectionFailure.Message}");
builder.AppendLine($"IED count : {devices.Count}");
builder.AppendLine($"Selected IED : {selectedDevice?.Name ?? "none"}");
builder.AppendLine();

builder.AppendLine("IED SNAPSHOT");
builder.AppendLine(new string('-', 72));
foreach (var device in devices)
{
builder.AppendLine(
$"{device.DeviceId} | {device.Name} | endpoint={device.EndpointText} | connected={device.IsConnected} | monitoring={device.IsMonitoring} | status={device.Status}");
}
if (devices.Count == 0)
builder.AppendLine("No IED card is present.");

builder.AppendLine();
builder.AppendLine("RECENT DIAGNOSTICS");
builder.AppendLine(new string('-', 72));
foreach (var entry in logs.OrderBy(item => item.Time).TakeLast(200))
{
builder.AppendLine(
$"{entry.Time:yyyy-MM-dd HH:mm:ss.fff} | {entry.Level} | {entry.Source} | {entry.Message}");
}
if (logs.Count == 0)
builder.AppendLine("No diagnostic entries.");

builder.AppendLine();
builder.AppendLine("NOTE");
builder.AppendLine(new string('-', 72));
builder.AppendLine("This fallback report intentionally skips network probes and complex aggregation so diagnostics remain copyable even when the normal collector encounters malformed workspace state.");
return builder.ToString();
}

public static async Task<string> BuildAsync(
IReadOnlyCollection<Iec61850MonitorDevice> devices,
IReadOnlyCollection<DiagnosticEntry> logs,
Expand All @@ -24,7 +74,14 @@ public static async Task<string> BuildAsync(
var appAssembly = Assembly.GetEntryAssembly() ?? typeof(DiagnosticReportBuilder).Assembly;
var engineAssembly = typeof(AR.Iec61850.Mms.MmsClientSession).Assembly;
var probes = await Task.WhenAll(devices.Select(device => ProbeDeviceAsync(device, cancellationToken))).ConfigureAwait(false);
var probeByEndpoint = probes.ToDictionary(result => result.Endpoint, StringComparer.OrdinalIgnoreCase);

// Diagnostics must remain available even when the workspace itself is malformed,
// duplicated, offline-only or contains multiple cards with the same endpoint text
// (for example "No endpoint"). DeviceId is the stable per-card identity; endpoint
// text is evidence, not a dictionary key.
var probeByDeviceId = probes
.GroupBy(result => result.DeviceId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);

var builder = new StringBuilder(64 * 1024);
builder.AppendLine("ARSAS Diagnostic Report");
Expand Down Expand Up @@ -69,7 +126,7 @@ public static async Task<string> BuildAsync(
{
cancellationToken.ThrowIfCancellationRequested();
var diagnostic = device.LastDiagnosticSnapshot ?? new Iec61850DeviceDiagnosticSnapshot();
probeByEndpoint.TryGetValue(device.EndpointText, out var probe);
probeByDeviceId.TryGetValue(device.DeviceId, out var probe);

builder.AppendLine($"IED : {device.Name}");
builder.AppendLine($"Endpoint : {device.EndpointText}");
Expand Down Expand Up @@ -274,10 +331,10 @@ private static async Task<TcpProbeResult> ProbeDeviceAsync(
CancellationToken cancellationToken)
{
if (device.IsConnected)
return new TcpProbeResult(device.EndpointText, "SKIPPED", "active MMS session already connected");
return new TcpProbeResult(device.DeviceId, device.EndpointText, "SKIPPED", "active MMS session already connected");

if (string.IsNullOrWhiteSpace(device.IpAddress) || device.Port is <= 0 or > 65535)
return new TcpProbeResult(device.EndpointText, "INVALID", "invalid endpoint");
return new TcpProbeResult(device.DeviceId, device.EndpointText, "INVALID", "invalid endpoint");

using var client = new TcpClient();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Expand All @@ -287,25 +344,26 @@ private static async Task<TcpProbeResult> ProbeDeviceAsync(
{
await client.ConnectAsync(device.IpAddress, device.Port, timeout.Token).ConfigureAwait(false);
stopwatch.Stop();
return new TcpProbeResult(device.EndpointText, "OPEN", $"TCP accepted in {stopwatch.Elapsed.TotalMilliseconds:0} ms");
return new TcpProbeResult(device.DeviceId, device.EndpointText, "OPEN", $"TCP accepted in {stopwatch.Elapsed.TotalMilliseconds:0} ms");
}
catch (SocketException ex)
{
stopwatch.Stop();
return new TcpProbeResult(
device.DeviceId,
device.EndpointText,
ex.SocketErrorCode == SocketError.ConnectionRefused ? "REFUSED" : "SOCKET_ERROR",
$"SocketError={ex.SocketErrorCode}; {ex.Message}; {stopwatch.Elapsed.TotalMilliseconds:0} ms");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
stopwatch.Stop();
return new TcpProbeResult(device.EndpointText, "TIMEOUT", $"> {stopwatch.Elapsed.TotalMilliseconds:0} ms");
return new TcpProbeResult(device.DeviceId, device.EndpointText, "TIMEOUT", $"> {stopwatch.Elapsed.TotalMilliseconds:0} ms");
}
catch (Exception ex)
{
stopwatch.Stop();
return new TcpProbeResult(device.EndpointText, "FAILED", $"{ex.GetType().Name}: {ex.Message}");
return new TcpProbeResult(device.DeviceId, device.EndpointText, "FAILED", $"{ex.GetType().Name}: {ex.Message}");
}
}

Expand Down
Loading