diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 21a055141..5a03abc5c 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -1707,7 +1707,13 @@ void Put(string name, string value, string authority) var wireSlots = _iedBrowserDetails.Rows .Where(row => row.GooseSlot >= 0 && row.GooseReference.Length == 0) .ToArray(); - var limit = Math.Min(frameView.Leaves.Count, 128); + // Unbound allData is evidence only if the ENTIRE received PDU + // has complete, contiguous wire slots. Do not project a partial + // decoder/model mismatch as real GOOSE values or as FCDA identity. + var unbound = ordered is null + ? IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView) + : null; + var limit = Math.Min(unbound?.Count ?? 0, 128); if (ordered is not null) { foreach (var slot in wireSlots) @@ -1751,9 +1757,9 @@ _iedBrowserGooseDirectory is { } directory && { if (row.GooseSlot < 0) continue; if (row.GooseReference.Length == 0 && - ordered is null && row.GooseSlot < frameView.Leaves.Count) + ordered is null && unbound is not null && row.GooseSlot < unbound.Count) { - row.Value = frameView.Leaves[row.GooseSlot].Value; + row.Value = unbound[row.GooseSlot].Value; row.ObservationSource = "GOOSE raw allData · unbound"; row.ObservationTooltip = $"allData[{row.GooseSlot + 1}] from {frameView.GoCbRef} " + diff --git a/Models/IedBrowserGooseInspection.cs b/Models/IedBrowserGooseInspection.cs index 246677872..d3fe77ffa 100644 --- a/Models/IedBrowserGooseInspection.cs +++ b/Models/IedBrowserGooseInspection.cs @@ -90,22 +90,52 @@ public static bool IsFrameCurrent( return matches.Length == 1 ? matches[0] : null; } + /// + /// Admit only a complete and contiguous received allData vector when no + /// exact FCDA directory can be verified. The returned entries remain + /// explicitly unbound: their position never proves an IEC reference or FC. + /// Validate the WHOLE PDU before showing even the first bounded UI rows. + /// + public static IReadOnlyList? OrderedUnboundWireEntries( + GooseStreamSnapshot frame) + { + ArgumentNullException.ThrowIfNull(frame); + var count = frame.WireDataSetEntryCount; + if (count <= 0 || count != frame.Leaves.Count) + return null; + for (var index = 0; index < count; index++) + { + var leaf = frame.Leaves[index]; + if (leaf.DataSetIndex != index || leaf.Order != index + 1 || + string.IsNullOrWhiteSpace(leaf.Value) || + leaf.Value == "") + return null; + } + return frame.Leaves; + } + public static IReadOnlyList? VerifiedMembers( GooseStreamSnapshot frame, LiveIedDataSetModel directory) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(directory); var members = directory.Members.OrderBy(x => x.Index).ToArray(); + // A cached directory is not evidence for a different received datSet. + // Bind its complete reference before interpreting any FCDA leaf. if (members.Length == 0 || frame.WireDataSetEntryCount != members.Length || frame.Leaves.Count != members.Length || - members.Select(x => x.Index).Distinct().Count() != members.Length) + string.IsNullOrWhiteSpace(directory.Reference) || + !string.Equals(Canonical(frame.DataSetReference), + Canonical(directory.Reference), StringComparison.Ordinal)) return null; - // Validate entire ordered vector before exposing any member value. + // Validate the full zero-based directory, one-based presentation order, + // and each exact wire reference/FC before publishing any member value. for (var slot = 0; slot < members.Length; slot++) { var declared = members[slot]; var wire = frame.Leaves[slot]; - if (wire.DataSetIndex != slot || + if (declared.Index != slot || wire.DataSetIndex != slot || + wire.Order != slot + 1 || Canonical(wire.SignalReference) != Canonical(declared.Reference) || !string.Equals(wire.FunctionalConstraint, declared.FunctionalConstraint, StringComparison.OrdinalIgnoreCase) || diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 69bb7157e..0cc15916c 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -440,7 +440,15 @@ public static IReadOnlyList BuildInitialPriority( // must be admitted through the same bounded read plan, not skipped // because the generic attribute walker cannot see the control block. // Only read-only NumOfSG/ActSG/LActTm; never EditSG/CnfEdit/SE. + // A syntactically valid SGCB/GoCB from an unrelated MMS domain is + // not an authority for this live association. Reuse one exact domain + // index for both bounded read-only control-state inventories. + var liveDomains = verifiedLiveModel.LogicalDevices + .Select(ld => ld.MmsDomain) + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .ToHashSet(StringComparer.Ordinal); var settings = verifiedLiveModel.SettingGroupControls + .Where(block => liveDomains.Contains(block.Domain)) .SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "SGCB")) .GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + target.FunctionalConstraint, @@ -460,6 +468,7 @@ public static IReadOnlyList BuildInitialPriority( // Limit background status reads for dense GoCB catalogs; unvisited // objects retain honest Not read evidence. var goose = verifiedLiveModel.GooseControlBlocks + .Where(block => liveDomains.Contains(block.Domain)) .SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "GoCB")) .GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + target.FunctionalConstraint, diff --git a/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs b/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs index 81873c29b..1218bd76f 100644 --- a/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs +++ b/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs @@ -50,6 +50,51 @@ public void UnverifiedOrConflictingSgcbNeverTriggersProbe( Assert.Empty(IedBrowserControlStateEvidence.ReadTargets(block, "SGCB")); } + [Fact] + public void InitialControlStateHydrationRejectsForeignMmsDomain() + { + var valid = Block(fields: ["ActSG"]); + var foreign = new LiveIedControlBlockModel + { + Name = "SGCB", Kind = "SettingGroupControl", + Domain = "FOREIGN", LogicalNode = "LLN0", + Reference = "FOREIGN/LLN0.SP.SGCB", + FunctionalConstraint = "SP", DiscoveryStatus = "AttributeInventoryOnly", + Attributes = ["ActSG"] + }; + var localGoose = Block("GSEControl", "GO", "BCU", + fields: ["GoEna"]); + var foreignGoose = new LiveIedControlBlockModel + { + Name = "BCU", Kind = "GSEControl", + Domain = "FOREIGN", LogicalNode = "LLN0", + Reference = "FOREIGN/LLN0.GO.BCU", + FunctionalConstraint = "GO", DiscoveryStatus = "AttributeInventoryOnly", + Attributes = ["GoEna"] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }], + SettingGroupControls = [foreign, valid], + GooseControlBlocks = [foreignGoose, localGoose] + }; + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var admitted = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32); + Assert.Contains(admitted, target => target.Reference == "LD/LLN0.SGCB.ActSG"); + Assert.Contains(admitted, target => target.Reference == "LD/LLN0.BCU.GoEna"); + Assert.DoesNotContain(admitted, target => + target.Reference.StartsWith("FOREIGN/", StringComparison.Ordinal)); + + model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }], + SettingGroupControls = [foreign], + GooseControlBlocks = [foreignGoose] + }; + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32)); + } + [Fact] public void GocbGoEnaAndNdsComAreMmsReads_NotPassiveFrameAssumptions() { diff --git a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs index 70148c3f8..f734c2590 100644 --- a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs +++ b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs @@ -33,6 +33,49 @@ public void ExactCanonicalControlIdentityAndOnePublisherRequired() Assert.Null(IedBrowserGooseInspection.Select(Expected(), [right, Frame()])); } + [Fact] + public void UnboundAllDataRejectsIncompleteDuplicateOrMissingRealWireSlots() + { + var first = new GooseLeafValueSnapshot(1, 0, "wire[1]", "", "", "", + "", "Closed [10]", "", false, "Unbound"); + var second = new GooseLeafValueSnapshot(2, 1, "wire[2]", "", "", "", + "", "Good", "", false, "Unbound"); + var frame = Frame(leaves: [first, second]); + var slots = IedBrowserGooseInspection.OrderedUnboundWireEntries(frame); + Assert.NotNull(slots); + Assert.Equal(2, slots.Count); + Assert.Same(first, slots[0]); + Assert.All(slots, slot => Assert.Empty(slot.SignalReference)); + Assert.All(slots, slot => Assert.Empty(slot.FunctionalConstraint)); + + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { WireDataSetEntryCount = 3 })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [second, first] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [first, first] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [first, second with { Order = 7 }] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [first, second with { Value = "" }] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [first, second with { Value = "" }] })); + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + frame with { Leaves = [] })); + + // The UI displays at most 128 entries; an invalid *129th* wire + // slot must still invalidate the entire PDU, including the first 128. + var large = Enumerable.Range(0, 129) + .Select(i => new GooseLeafValueSnapshot(i + 1, i, + $"wire[{i + 1}]", "", "", "", "", "Good", "", false, "Unbound")) + .ToArray(); + Assert.Equal(129, IedBrowserGooseInspection.OrderedUnboundWireEntries( + Frame(leaves: large))?.Count); + large[128] = large[128] with { DataSetIndex = 127 }; + Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries( + Frame(leaves: large))); + } + [Fact] public void ExactSclGoCbMetadata_ProjectsOnlyConfiguredVlanAndTimingFields() { @@ -78,8 +121,28 @@ public void WireDataSetValuesOnlyIfWholeOrderedMemberIdentityMatches() { Leaves = [b, a] }, set)); Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with { WireDataSetEntryCount = 1 }, set)); + Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with + { DataSetReference = "LD/LLN0$DIFFERENT" }, set)); + Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with + { Leaves = [a, b with { Order = 1 }] }, set)); Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with { Leaves = [a, b with { FunctionalConstraint = "MX" }] }, set)); + var nonContiguous = new LiveIedDataSetModel + { + Reference = set.Reference, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 1, Reference = first.Reference, FunctionalConstraint = "ST" + }, + new LiveIedDataSetMemberModel + { + Index = 2, Reference = second.Reference, FunctionalConstraint = "ST" + } + ] + }; + Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, nonContiguous)); var ambiguous = new LiveIedDataSetModel { Reference = set.Reference, Members = [first, first] }; Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, ambiguous)); @@ -229,6 +292,7 @@ public void BrowserHasUnboundWireAllDataFallbackWithoutRewritingLedger() "MainWindow.IedBrowser.cs")); Assert.Contains("ResolveWireDataSet(", source); Assert.Contains("allData[{slot + 1}] · unbound", source); + Assert.Contains("IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView)", source); Assert.Contains("GOOSE raw allData · unbound", source); Assert.Contains("Not current", source); Assert.Contains("Received GOOSE PDU datSet", source); diff --git a/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs b/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs new file mode 100644 index 000000000..b2db7c9a1 --- /dev/null +++ b/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs @@ -0,0 +1,118 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// +/// Retains the unique live-wire and initial-SGCB admission contracts of +/// historical #606 against the CURRENT RC API. No obsolete UI navigation +/// assertions, no second GOOSE decoder, no MMS mutation and no device I/O. +/// +public sealed class M217DCurrentTreeEvidenceContinuityTests +{ + [Fact] + public void ExactWireGoCbCanBeReadUnboundWithoutAnMmsDataSetInventory() + { + var first = new GooseLeafValueSnapshot(1, 0, "raw-1", "", "", "", "", + "Open [01]", "", false, "Unbound"); + var second = new GooseLeafValueSnapshot(2, 1, "raw-2", "", "", "", "", + "Good", "", false, "Unbound"); + var frame = new GooseStreamSnapshot( + "received-wire", "0x0021", "LD/LLN0$GO$BCU", "BCUGE03", + "LD/LLN0$GOOSE1", "02:00:4C:4F:5B:50", + "01:0C:CD:01:00:33", "VID 0 / PCP 4", + "1", "42", "Healthy", "20000 ms", "1", + "", "Unbound", "", "12:00:00.000", 1, 0, + false, false, [first, second], + "0", "4", 2); + // There is no MMS/SCL DatSet inventory and publisher IED label is + // absent. Only the full received GoCB reference authorizes a match. + var expected = new IedBrowserGooseExpectedIdentity( + "BCUGE", "LD/LLN0.GO.BCU", "", "BCUGE03", + 33, 1, "", "", null); + Assert.Same(frame, IedBrowserGooseInspection.Select(expected, [frame])); + var raw = IedBrowserGooseInspection.OrderedUnboundWireEntries(frame); + Assert.NotNull(raw); + Assert.Equal(2, raw.Count); + Assert.Equal("Good", raw[1].Value); + Assert.All(raw, slot => Assert.Empty(slot.SignalReference)); + Assert.All(raw, slot => Assert.Empty(slot.FunctionalConstraint)); + Assert.Null(IedBrowserGooseInspection.Select( + expected with { GoCbRef = "" }, [frame])); + Assert.Null(IedBrowserGooseInspection.Select( + expected, [frame with { GoCbRef = "LD/LLN0$GO$OTHER" }])); + Assert.Null(IedBrowserGooseInspection.Select( + expected, [frame, frame])); + Assert.Null(IedBrowserGooseInspection.Select( + expected, [frame with { AppIdText = "0x0022" }])); + } + + [Fact] + public void ExactLiveSgcbInitialReadIsBoundedReadOnlyAndSkipsObservedFields() + { + var block = new LiveIedControlBlockModel + { + Kind = "SettingGroupControl", + Domain = "LD", LogicalNode = "LLN0", Name = "SGCB", + FunctionalConstraint = "SP", + DiscoveryStatus = "AttributeInventoryOnly", + Reference = "LD/LLN0.SP.SGCB", + Attributes = ["NumOfSG", "ActSG", "LActTm", "EditSG", "CnfEdit"] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }], + SettingGroupControls = [block] + }; + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + var original = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32); + Assert.Equal(3, original.Count); + Assert.All(original, entry => Assert.Equal("SP", entry.FunctionalConstraint)); + Assert.Equal(new[] { "ActSG", "LActTm", "NumOfSG" }, + original.Select(target => target.Reference.Split('.')[^1]) + .OrderBy(name => name, StringComparer.Ordinal).ToArray()); + Assert.DoesNotContain(original, target => + target.Reference.Contains("EditSG", StringComparison.Ordinal) || + target.Reference.Contains("CnfEdit", StringComparison.Ordinal)); + + Assert.True(ledger.PublishForAssociation(association, + "LD/LLN0.SGCB.ActSG", "SP", "1", "", "", "MMS Read")); + var pending = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32); + Assert.Equal(2, pending.Count); + Assert.DoesNotContain(pending, target => + target.Reference == "LD/LLN0.SGCB.ActSG"); + Assert.Single(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 1)); + ledger.MarkOffline(); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32)); + } + + [Fact] + public void UnverifiedSclOrWrongFcNeverBecomesAnInitialSgcbRead() + { + LiveIedModelDiscoveryDocument Create(string status, string fc) => new() + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }], + SettingGroupControls = + [ + new LiveIedControlBlockModel + { + Kind = "SettingGroupControl", Domain = "LD", + LogicalNode = "LLN0", Name = "SGCB", + FunctionalConstraint = fc, + DiscoveryStatus = status, + Reference = $"LD/LLN0.{fc}.SGCB", + Attributes = ["NumOfSG", "ActSG", "EditSG", "CnfEdit"] + } + ] + }; + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority( + Create("SCL", "SP"), ledger, 32)); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority( + Create("AttributeInventoryOnly", "CF"), ledger, 32)); + Assert.Empty(IedBrowserReadPlan.BuildInitialPriority( + Create("AttributeInventoryOnly", "SP"), ledger, 0)); + } +}