diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs
index fd277a8c7..44275a068 100644
--- a/MainWindow.IedBrowser.cs
+++ b/MainWindow.IedBrowser.cs
@@ -733,18 +733,15 @@ private void UpdateIedBrowserControlStateDetails()
}
else
{
- var target = IedBrowserControlStateEvidence.ReadTargets(block, pageKind)
- .FirstOrDefault(item => item.Reference.EndsWith(
- "." + field, StringComparison.Ordinal));
- if (target is not null &&
- _iedBrowserReadFailureEvidence?.TryGet(device,
- target.Reference, "", target.FunctionalConstraint,
- out var failure) == true)
+ if (IedBrowserControlStateEvidence.TryReadFailure(
+ device, block, pageKind, field,
+ _iedBrowserReadFailureEvidence, device.InitialHydrationFailures,
+ out var failure, out var source, out var failedReference))
{
row.Value = "Unavailable";
- row.ObservationSource = "MMS Read failed";
+ row.ObservationSource = source;
row.ObservationTooltip =
- $"{target.Reference} [{target.FunctionalConstraint}] · {failure}";
+ $"{failedReference} [{block.FunctionalConstraint}] · {source} · {failure}";
}
else
{
diff --git a/Models/IedBrowserControlStateEvidence.cs b/Models/IedBrowserControlStateEvidence.cs
index c50073eea..cbaae22d8 100644
--- a/Models/IedBrowserControlStateEvidence.cs
+++ b/Models/IedBrowserControlStateEvidence.cs
@@ -84,6 +84,42 @@ public static bool TryRead(
}
return true;
}
+ ///
+ /// Error-only status for exactly the verified SGCB / GoCB MMS leaf.
+ /// Manual Read's latest attempt takes precedence over initial hydration;
+ /// neither failure is allowed to claim a live value or new FC.
+ /// The consumer checks LiveValues success before calling this method.
+ ///
+ public static bool TryReadFailure(
+ Iec61850MonitorDevice device, LiveIedControlBlockModel block,
+ string pageKind, string field,
+ IedBrowserReadFailureEvidence? manual,
+ IedBrowserReadFailureEvidence? initial,
+ out string reason, out string source, out string exactReference)
+ {
+ reason = "";
+ source = "";
+ exactReference = "";
+ var target = ReadTargets(block, pageKind).FirstOrDefault(item =>
+ item.Reference.EndsWith("." + field, StringComparison.Ordinal));
+ if (target is null) return false;
+ exactReference = target.Reference;
+ if (manual?.TryGet(device, target.Reference, "",
+ target.FunctionalConstraint, out reason) == true)
+ {
+ source = "MMS manual Read failed";
+ return true;
+ }
+ if (initial?.TryGet(device, target.Reference, "",
+ target.FunctionalConstraint, out reason) == true)
+ {
+ source = "MMS initial Read failed";
+ return true;
+ }
+ reason = "";
+ return false;
+ }
+
///
/// SGCB LActTm is a real relay value, but an all-zero wire UTC-Time
/// denotes an unset time rather than an actual group-change in 1970.
diff --git a/Models/IedBrowserReadFailureEvidence.cs b/Models/IedBrowserReadFailureEvidence.cs
index 696b24f9a..65f70cc9c 100644
--- a/Models/IedBrowserReadFailureEvidence.cs
+++ b/Models/IedBrowserReadFailureEvidence.cs
@@ -9,6 +9,9 @@ namespace ArIED61850Tester.Models;
/// conversion, retry or live state. Exact reference/FC, same IED object,
/// association and projection epoch are required on every UI lookup.
///
+public sealed record IedBrowserReadFailureSample(
+ string Reference, string FunctionalConstraint, string Reason);
+
public sealed class IedBrowserReadFailureEvidence
{
private const int MaxFailureEntries = 4096;
@@ -68,6 +71,32 @@ public void ClearFailure(IedBrowserReadTarget target)
_failed.Remove(Key(target.Reference, target.FunctionalConstraint));
}
+ ///
+ /// Bounded immutable sample of error-only evidence from the current IED,
+ /// association and verified model/epoch. No process values or MMS IO.
+ ///
+ public IReadOnlyList SnapshotCurrent(
+ Iec61850MonitorDevice device, int maxEntries = 12)
+ {
+ if (maxEntries <= 0 || !IsCurrent(device))
+ return Array.Empty();
+ lock (_gate)
+ {
+ if (!IsCurrent(device))
+ return Array.Empty();
+ return _failed.OrderBy(item => item.Key, StringComparer.Ordinal)
+ .Take(Math.Min(maxEntries, 32))
+ .Select(item =>
+ {
+ var separator = item.Key.LastIndexOf('|');
+ return new IedBrowserReadFailureSample(
+ item.Key[..separator], item.Key[(separator + 1)..],
+ item.Value);
+ })
+ .ToArray();
+ }
+ }
+
public bool TryGet(Iec61850MonitorDevice device,
string reference, string fallbackReference, string fc, out string reason)
{
diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs
index fd67a132c..215cad91f 100644
--- a/Services/DiagnosticReportBuilder.cs
+++ b/Services/DiagnosticReportBuilder.cs
@@ -166,6 +166,18 @@ public static async Task BuildAsync(
}
builder.AppendLine($"Connected : {device.IsConnected}");
builder.AppendLine($"Monitoring : {device.IsMonitoring}");
+ // Diagnostic projection only: no MMS reads, new caches, or
+ // synthetic values. Samples are scoped to the active IED.
+ builder.AppendLine($"Browser ledger : association={device.LiveValues.Association}, version={device.LiveValues.Version}, offline={device.LiveValues.IsOffline}");
+ var hydrationFailures = device.InitialHydrationFailures?
+ .SnapshotCurrent(device, maxEntries: 12) ??
+ Array.Empty();
+ builder.AppendLine($"Initial MMS errors (sample max 12): {hydrationFailures.Count}");
+ foreach (var evidence in hydrationFailures)
+ {
+ var detail = evidence.Reason.Replace('\r', ' ').Replace('\n', ' ');
+ builder.AppendLine($" {evidence.Reference} [{evidence.FunctionalConstraint}] : {detail}");
+ }
builder.AppendLine($"Busy : {device.IsBusy}");
builder.AppendLine($"Status : {device.Status}");
builder.AppendLine($"Detail : {device.Detail}");
diff --git a/tests/ARSAS.Tests/M217DControlReadFailureDiagnosticsTests.cs b/tests/ARSAS.Tests/M217DControlReadFailureDiagnosticsTests.cs
new file mode 100644
index 000000000..afecbcbc8
--- /dev/null
+++ b/tests/ARSAS.Tests/M217DControlReadFailureDiagnosticsTests.cs
@@ -0,0 +1,141 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+
+namespace ARSAS.Tests;
+
+public sealed class M217DControlReadFailureDiagnosticsTests
+{
+ private static LiveIedControlBlockModel Block(string kind = "SettingGroupControl",
+ string fc = "SP", string name = "SGCB", string status = "AttributeInventoryOnly")
+ => new()
+ {
+ Domain = "LD", LogicalNode = "LLN0", Name = name,
+ Kind = kind, FunctionalConstraint = fc,
+ DiscoveryStatus = status,
+ Reference = $"LD/LLN0.{fc}.{name}",
+ Attributes = kind == "SettingGroupControl"
+ ? ["NumOfSG", "ActSG", "LActTm", "EditSG"]
+ : ["GoEna", "NdsCom"]
+ };
+
+ private static (Iec61850MonitorDevice Device, IedBrowserReadFailureEvidence Initial)
+ Connected()
+ {
+ var model = new LiveIedModelDiscoveryDocument
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }]
+ };
+ var device = new Iec61850MonitorDevice
+ {
+ LiveDiscoveryModel = model, IsConnected = true
+ };
+ return (device, new IedBrowserReadFailureEvidence(
+ device, bindToLiveModel: true));
+ }
+
+ [Fact]
+ public void VerifiedSgcbInitialReadErrorIsSurfacedWithoutInventingValue()
+ {
+ var (device, initial) = Connected();
+ var block = Block();
+ initial.RecordFailure(new IedBrowserReadTarget(
+ "LD/LLN0$SP$SGCB$ActSG", "SP", "UInt32"), "data-access-error: object-non-existent");
+
+ Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device,
+ block, "SGCB", "ActSG", null, initial,
+ out var reason, out var origin, out var reference));
+ Assert.Equal("MMS initial Read failed", origin);
+ Assert.Equal("LD/LLN0.SGCB.ActSG", reference);
+ Assert.Contains("object-non-existent", reason);
+ Assert.False(device.LiveValues.TryGet(reference, "SP", out _));
+ Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device,
+ block, "SGCB", "EditSG", null, initial, out _, out _, out _));
+ Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device,
+ block, "SGCB", "ActSG", null, initial, out _, out _, out _)
+ && device.LiveValues.IsOffline);
+ }
+
+ [Fact]
+ public void ManualFailureTakesPrecedenceThenFallsBackAfterCatalogReplacement()
+ {
+ var (device, initial) = Connected();
+ var block = Block();
+ var target = new IedBrowserReadTarget(
+ "LD/LLN0.SGCB.NumOfSG", "SP", "UInt32");
+ initial.RecordFailure(target, "initial denied");
+ var manual = new IedBrowserReadFailureEvidence(device);
+ manual.RecordFailure(target, "manual timeout");
+ Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device,
+ block, "SGCB", "NumOfSG", manual, initial,
+ out var reason, out var origin, out _));
+ Assert.Equal("manual timeout", reason);
+ Assert.Equal("MMS manual Read failed", origin);
+ device.Signals.ReplaceAll([new SignalDefinition
+ {
+ ObjectReference = "LD/LLN0.Mod.stVal", FunctionalConstraint = "ST"
+ }]);
+ Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device,
+ block, "SGCB", "NumOfSG", manual, initial,
+ out reason, out origin, out _));
+ Assert.Equal("initial denied", reason);
+ Assert.Equal("MMS initial Read failed", origin);
+ }
+
+ [Fact]
+ public void GoCbReadFailureIsNotGooseFrameEvidenceOrAnSclOnlyAssumption()
+ {
+ var (device, initial) = Connected();
+ var block = Block(kind: "GSEControl", fc: "GO", name: "BCU");
+ initial.RecordFailure(new IedBrowserReadTarget(
+ "LD/LLN0.BCU.GoEna", "GO", "Boolean"), "MMS reject");
+ Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device,
+ block, "GoCB", "GoEna", null, initial,
+ out _, out var source, out _));
+ Assert.Equal("MMS initial Read failed", source);
+ Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device,
+ block, "GoCB", "NdsCom", null, initial, out _, out _, out _));
+ Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device,
+ Block(kind: "GSEControl", fc: "GO", name: "BCU", status: "SCLOnly"),
+ "GoCB", "GoEna", null, initial, out _, out _, out _));
+ }
+
+ [Fact]
+ public void FailureDiagnosticSampleIsBoundedSortedAndOnlyCurrentAssociation()
+ {
+ var (device, initial) = Connected();
+ for (var i = 40; i >= 1; i--)
+ initial.RecordFailure(new IedBrowserReadTarget(
+ $"LD/LLN0.Mod{i:000}.stVal", "ST", "Enum"),
+ "read error " + i);
+ var sample = initial.SnapshotCurrent(device, maxEntries: 200);
+ Assert.Equal(32, sample.Count);
+ Assert.Equal("LD/LLN0.Mod001.stVal", sample[0].Reference);
+ Assert.All(sample, s => Assert.Equal("ST", s.FunctionalConstraint));
+ Assert.Empty(initial.SnapshotCurrent(device, maxEntries: 0));
+ var (other, _) = Connected();
+ Assert.Empty(initial.SnapshotCurrent(other));
+ device.IsConnected = false;
+ Assert.Empty(initial.SnapshotCurrent(device));
+ device.IsConnected = true;
+ Assert.Empty(initial.SnapshotCurrent(device));
+ }
+
+ [Fact]
+ public void DiagnosticsUseExistingFailureLedgerWithoutNetworkReadOrShadowCache()
+ {
+ DirectoryInfo? folder = new(AppContext.BaseDirectory);
+ while (folder is not null &&
+ !File.Exists(Path.Combine(folder.FullName, "Services", "DiagnosticReportBuilder.cs")))
+ folder = folder.Parent;
+ Assert.NotNull(folder);
+ var report = File.ReadAllText(Path.Combine(folder!.FullName,
+ "Services", "DiagnosticReportBuilder.cs"));
+ var browser = File.ReadAllText(Path.Combine(folder.FullName,
+ "MainWindow.IedBrowser.cs"));
+ Assert.Contains("SnapshotCurrent(device, maxEntries: 12)", report);
+ Assert.Contains("Browser ledger : association=", report);
+ Assert.Contains("Initial MMS errors (sample max 12)", report);
+ Assert.Contains("device.InitialHydrationFailures,", browser);
+ Assert.Contains("IedBrowserControlStateEvidence.TryReadFailure(", browser);
+ }
+}