diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index a151139ee..c049105f0 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -656,7 +656,12 @@ private void PrepareIedBrowserGoose(Iec61850MonitorDevice device, var dataSet = !string.IsNullOrWhiteSpace(block.DataSetReference) ? block.DataSetReference : scl?.DataSetReference ?? ""; - _iedBrowserGooseDirectory = model?.DataSets.FirstOrDefault(entry => + // Exact selected-IED SCL DataSet is a directory fallback, never a + // received subscriber frame or a substitute for live MMS state. + var directoryModel = IedBrowserGooseDirectory.ModelForDataSet( + model, device.SclWorkspace?.DesignModel, block); + _iedBrowserGooseDirectory = directoryModel?.DataSets.FirstOrDefault(entry => + string.Equals(entry.Domain, block.Domain, StringComparison.Ordinal) && IedBrowserGooseInspection.Canonical(entry.Reference) == IedBrowserGooseInspection.Canonical(dataSet)); _iedBrowserGooseExpected = new IedBrowserGooseExpectedIdentity( @@ -949,7 +954,8 @@ private void RefreshIedBrowser() _iedBrowserCachedSourceStamp = stamp; var roots = IedBrowserPresentation.Roots(model, _iedBrowserCachedRcbRows, - _iedBrowserRemoteFiles, EffectiveSclIedName(device)); + _iedBrowserRemoteFiles, EffectiveSclIedName(device), + device.SclWorkspace?.DesignModel); _iedBrowserTree.Reset(roots, expandedKeys); // Reports is the familiar entry point; selecting the category shows its // full RCB list on the right. Preserve explicit expansion on same-IED refresh. diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index c4993dd42..ffe77297c 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -126,6 +126,16 @@ public Iec61850MonitorDevice? SelectedDevice public MainWindow() { InitializeComponent(); + // The actual deployed assembly version, never an invented UI number. + var assembly = typeof(MainWindow).Assembly; + var compiled = assembly.GetCustomAttributes( + typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion; + var version = (compiled ?? assembly.GetName().Version?.ToString() ?? "") + .Split('+', 2)[0].TrimStart('v', 'V'); + if (version.Length > 0) + Title = $"ARSAS v{version} - Smart IEC 61850 Communication Tester"; DataContext = this; _controlShoutTimer.Tick += (_, _) => { diff --git a/Models/IedBrowserGooseDirectory.cs b/Models/IedBrowserGooseDirectory.cs new file mode 100644 index 000000000..2f4db36da --- /dev/null +++ b/Models/IedBrowserGooseDirectory.cs @@ -0,0 +1,54 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// Model directory projection only. A selected IED's SCL may declare GoCBs +/// that optional online MMS discovery does not enumerate. Do not interpret +/// SCL declarations as live GoEna, subscriber frames or permission to read. +/// +public static class IedBrowserGooseDirectory +{ + public static IReadOnlyList Resolve( + LiveIedModelDiscoveryDocument? live, + LiveIedModelDiscoveryDocument? scl) + { + if (live is null) return scl?.GooseControlBlocks ?? []; + var blocks = new List(live.GooseControlBlocks); + if (scl is null) return blocks; + // Attached SCL was already selected for this IED; only LD identities + // actually present in its live model may enrich the online directory. + var verifiedDomains = live.LogicalDevices + .Select(ld => ld.MmsDomain) + .ToHashSet(StringComparer.Ordinal); + var seen = blocks.Select(b => b.Reference) + .ToHashSet(StringComparer.Ordinal); + foreach (var block in scl.GooseControlBlocks) + { + if (block.Reference.Length == 0 || block.Domain.Length == 0 || + !verifiedDomains.Contains(block.Domain) || + !seen.Add(block.Reference)) + continue; + blocks.Add(block); + } + return blocks; + } + + public static LiveIedModelDiscoveryDocument? ModelForDataSet( + LiveIedModelDiscoveryDocument? live, + LiveIedModelDiscoveryDocument? scl, + LiveIedControlBlockModel block) + { + static bool HasExact(LiveIedModelDiscoveryDocument? candidate, + LiveIedControlBlockModel target) + => candidate?.DataSets.Any(set => + string.Equals(set.Reference, target.DataSetReference, + StringComparison.Ordinal) && + string.Equals(set.Domain, target.Domain, StringComparison.Ordinal)) == true; + if (HasExact(live, block)) return live; + if (HasExact(scl, block) && (live is null || + live.LogicalDevices.Any(ld => ld.MmsDomain == block.Domain))) + return scl; + return live; + } +} diff --git a/Models/IedBrowserPresentation.cs b/Models/IedBrowserPresentation.cs index 31a6ae0a4..90b891924 100644 --- a/Models/IedBrowserPresentation.cs +++ b/Models/IedBrowserPresentation.cs @@ -293,7 +293,8 @@ public static class IedBrowserPresentation public static IReadOnlyList Roots( LiveIedModelDiscoveryDocument? model, IReadOnlyList rcbs, IReadOnlyList? remoteFiles = null, - string? iedName = null) + string? iedName = null, + LiveIedModelDiscoveryDocument? sclModel = null) { const int childDepth = 1; // Keep domain/object keys untouched; only presentation labels are short. @@ -305,7 +306,8 @@ IedBrowserTreeRow Category(string name, string key, var categories = new List { - Category("GOOSE", "goose", () => BlockGroups(model?.GooseControlBlocks, "GoCB", labelIed)), + Category("GOOSE", "goose", () => BlockGroups( + IedBrowserGooseDirectory.Resolve(model, sclModel), "GoCB", labelIed)), Category("Reports", "reports", () => ReportGroups(rcbs, labelIed)), Category("Setting Groups", "settings", () => BlockGroups(model?.SettingGroupControls, "SGCB", labelIed)), Category("Files", "files", () => @@ -581,7 +583,8 @@ void Member(string name, string value = "Not read", int depth = 0, string source rows.Add(new("Message", "DataSet member directory is not available in the current model.")); else foreach (var member in linkedDataSet.Members) - rows.Add(IedBrowserStructuredDetails.Member(member, model)); + rows.Add(IedBrowserStructuredDetails.Member( + member, model, sclModel: sclModel)); break; case LiveIedDataSetModel set: Section("DataSet attributes"); @@ -592,7 +595,8 @@ void Member(string name, string value = "Not read", int depth = 0, string source Section("Data"); rows.Add(new("DataHeader", "Name", "Value")); foreach (var member in set.Members) - rows.Add(IedBrowserStructuredDetails.Member(member, model)); + rows.Add(IedBrowserStructuredDetails.Member( + member, model, sclModel: sclModel)); break; case LiveIedControlBlockModel block: Section("Control Block attributes"); @@ -707,16 +711,19 @@ void Member(string name, string value = "Not read", int depth = 0, string source Field("Frame DataSet entries", "—", "Passive GOOSE"); Section("Data"); rows.Add(new("DataHeader", "Name", "Value")); - var linkedGooseSet = FindDataSet(model, block.DataSetReference); + var gooseDirectoryModel = + IedBrowserGooseDirectory.ModelForDataSet(model, sclModel, block); + var linkedGooseSet = FindDataSet(gooseDirectoryModel, block.DataSetReference); if (linkedGooseSet is null) - rows.Add(new("Message", "No verified DataSet member directory for this GoCB.")); + rows.Add(new("Message", "No exact DataSet member directory for this GoCB in selected IED models.")); else { var ordered = linkedGooseSet.Members.OrderBy(m => m.Index).ToArray(); for (var slot = 0; slot < ordered.Length; slot++) { var member = ordered[slot]; - rows.Add(IedBrowserStructuredDetails.Member(member, model) with + rows.Add(IedBrowserStructuredDetails.Member(member, + gooseDirectoryModel) with { GooseSlot = slot, GooseReference = member.Reference, diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 59f267fa7..568204757 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -85,6 +85,10 @@ public static IReadOnlyList Build( string.IsNullOrWhiteSpace(a.SclBType) ? a.MmsType : a.SclBType)); // Exact, model-inventory-proven control state is an additional // read-only projection. It is never added to cyclic or Read All scans. + // ReadTargets already enforces the status and FC of the verified + // attribute inventory, including refusal of SCL-only declarations. + // A separate global-GoCB-membership check wrongly rejects legitimate + // independently verified MMS control inventories. var control = !readAll && page?.Payload is LiveIedControlBlockModel inspectionBlock && page.Kind is "SGCB" or "GoCB" ? IedBrowserControlStateEvidence.ReadTargets(inspectionBlock, page.Kind) diff --git a/Models/IedBrowserStructuredDetails.cs b/Models/IedBrowserStructuredDetails.cs index cf0f28e01..fe8868990 100644 --- a/Models/IedBrowserStructuredDetails.cs +++ b/Models/IedBrowserStructuredDetails.cs @@ -17,10 +17,9 @@ public static IedBrowserDetailRow DataObject( // registry; there is no second value cache or additional MMS read. var hasLeaf = IedBrowserSchemaIndex.TryGetUniqueProcessLeaf( obj, out var processLeaf, out var processFc); - var scalarComponents = hasLeaf - ? Array.Empty() - : IedBrowserMultiLeafPresentation.BuildPlan( - obj.Attributes, obj.Reference, "ST"); + var scalarComponents = IedBrowserMultiLeafPresentation.BuildPlan( + obj.Attributes, obj.Reference, "ST"); + if (scalarComponents.Count > 1) hasLeaf = false; var allowVector = !IedBrowserEngineeringComposite.IsScalarCdc(obj.InferredCdc); var phasePlans = allowVector ? IedBrowserEngineeringComposite.BuildThreePhases( @@ -68,7 +67,8 @@ public static IedBrowserDetailRow DataObject( } public static IedBrowserDetailRow Member(LiveIedDataSetMemberModel member, - LiveIedModelDiscoveryDocument? model, int depth = 0) + LiveIedModelDiscoveryDocument? model, int depth = 0, + LiveIedModelDiscoveryDocument? sclModel = null) { var name = member.Reference; var slash = name.IndexOf('/'); @@ -102,15 +102,33 @@ public static IedBrowserDetailRow Member(LiveIedDataSetMemberModel member, .Where(a => a.Reference.StartsWith(a.Prefix, StringComparison.Ordinal)) .Select(a => (Reference: a.Reference, Relative: a.Reference[a.Prefix.Length..]))); var foundLeaf = !string.IsNullOrEmpty(leaf); - // Only a whole FC=ST FCD containing several exact process leaves - // receives a labelled summary. A single FCDA or a scalar leaf keeps - // its original direct-binding semantics. - var scalarComponents = !foundLeaf && exactObject is not null && - string.Equals(identity, exactObject.Reference, StringComparison.Ordinal) && - string.Equals(member.FunctionalConstraint, "ST", StringComparison.OrdinalIgnoreCase) - ? IedBrowserMultiLeafPresentation.BuildPlan( - exactObject.Attributes, identity, "ST") - : Array.Empty(); + // An exact selected-IED SCL DO can supply missing typed ST directory + // declarations, but never MMS evidence or implicit read authority. + var wholeStObject = string.Equals(member.FunctionalConstraint, "ST", + StringComparison.OrdinalIgnoreCase) && + (exactObject is null || string.Equals(identity, + IedBrowserLiveValues.CanonicalReference(exactObject.Reference), + StringComparison.Ordinal)); + var scalarComponents = wholeStObject && exactObject is not null + ? IedBrowserMultiLeafPresentation.BuildPlan( + exactObject.Attributes, identity, "ST") + : Array.Empty(); + if (wholeStObject && scalarComponents.Count < 2 && sclModel is not null) + { + var sclOwners = IedBrowserEngineeringObjects.GroupExact( + sclModel.LogicalDevices.SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects) + .Where(obj => string.Equals( + IedBrowserLiveValues.CanonicalReference(obj.Reference), + identity, StringComparison.Ordinal)).ToArray()); + if (sclOwners.Count == 1) + { + var candidate = IedBrowserMultiLeafPresentation.BuildPlan( + sclOwners[0].Attributes, identity, "ST"); + if (candidate.Count > 1) scalarComponents = candidate; + } + } + if (scalarComponents.Count > 1) foundLeaf = false; var matchingMembers = exactObject?.Attributes.Where(a => string.Equals(a.FunctionalConstraint, member.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)).ToArray() diff --git a/tests/ARSAS.Tests/M214GooseSclDirectoryRecoveryTests.cs b/tests/ARSAS.Tests/M214GooseSclDirectoryRecoveryTests.cs new file mode 100644 index 000000000..10c897714 --- /dev/null +++ b/tests/ARSAS.Tests/M214GooseSclDirectoryRecoveryTests.cs @@ -0,0 +1,84 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M214GooseSclDirectoryRecoveryTests +{ + private static LiveIedControlBlockModel Block(string name, string domain = "LD") + => new() + { + Name = name, Kind = "GOOSE", Domain = domain, + LogicalNode = "LLN0", Reference = domain + "/LLN0.GO." + name, + DataSetReference = domain + "/LLN0.DS" + }; + + private static LiveIedModelDiscoveryDocument Model( + string domain, LiveIedControlBlockModel[] blocks, + LiveIedDataSetModel[]? sets = null) + => new() + { + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = domain }], + GooseControlBlocks = blocks, + DataSets = sets ?? [] + }; + + [Fact] + public void MissingLiveGoCb_RestoresSelectedSclControlWithoutClaimingState() + { + var live = Model("LD", []); + var scl = Model("LD", [Block("gcb1")]); + var resolved = IedBrowserGooseDirectory.Resolve(live, scl); + var block = Assert.Single(resolved); + var category = Assert.Single(IedBrowserPresentation.Roots(live, + Array.Empty(), sclModel: scl) + .Where(x => x.Key == "category/goose")); + var ld = Assert.Single(category.GetChildren()); + var ln = Assert.Single(ld.GetChildren()); + var node = Assert.Single(ln.GetChildren()); + Assert.Equal("GoCB", node.Kind); + Assert.Equal(block.Reference, ((LiveIedControlBlockModel)node.Payload!).Reference); + var details = IedBrowserPresentation.Details(node, live, true, scl); + Assert.Contains(details, r => r.Kind == "Property" && + r.Name == "Enabled" && r.Value == "Not read"); + } + + [Fact] + public void OnlineControlIsAuthoritative_SclDuplicateDoesNotReplaceIt() + { + var online = Block("gcb1"); + var live = Model("LD", [online]); + var scl = Model("LD", [Block("gcb1"), Block("gcb2")]); + var blocks = IedBrowserGooseDirectory.Resolve(live, scl); + Assert.Equal(2, blocks.Count); + Assert.Same(online, blocks[0]); + Assert.Equal(["gcb1", "gcb2"], blocks.Select(x => x.Name).ToArray()); + } + + [Fact] + public void ForeignSclLogicalDeviceCannotCreatePhantomGoose() + { + var live = Model("LD", []); + var foreign = Model("OTHER", [Block("alien", "OTHER")]); + Assert.Empty(IedBrowserGooseDirectory.Resolve(live, foreign)); + } + + [Fact] + public void SclDataSetIsOnlySelectedForExactDomainAndReference() + { + var block = Block("gcb1"); + var live = Model("LD", []); + var set = new LiveIedDataSetModel + { + Domain = "LD", LogicalNode = "LLN0", Name = "DS", + Reference = "LD/LLN0.DS" + }; + var scl = Model("LD", [block], [set]); + Assert.Same(scl, IedBrowserGooseDirectory.ModelForDataSet(live, scl, block)); + Assert.Same(live, IedBrowserGooseDirectory.ModelForDataSet(live, + Model("OTHER", [Block("foreign", "OTHER")], + [new LiveIedDataSetModel { + Domain = "OTHER", Reference = "LD/LLN0.DS" + }]), block)); + } +} diff --git a/tests/ARSAS.Tests/M214SclOnlyGooseReadAndDataSetParentTests.cs b/tests/ARSAS.Tests/M214SclOnlyGooseReadAndDataSetParentTests.cs new file mode 100644 index 000000000..ecadc284d --- /dev/null +++ b/tests/ARSAS.Tests/M214SclOnlyGooseReadAndDataSetParentTests.cs @@ -0,0 +1,90 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M214SclOnlyGooseReadAndDataSetParentTests +{ + private const string Owner = "LD/ATCC1.TapChg"; + private static LiveIedDataAttributeModel Field(string path) => new() + { + ObjectReference = Owner, AttributePath = "TapChg." + path, + FunctionalConstraint = "ST", MmsReference = + "LD/ATCC1$ST$TapChg$" + path.Replace('.', '$') + }; + private static LiveIedModelDiscoveryDocument Model( + LiveIedDataAttributeModel[] attrs) + => new() + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", + LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "ATCC1", + DataObjects = [new LiveIedDataObjectModel + { + Reference = Owner, Name = "TapChg", + Attributes = attrs + }] + }] + }], + DataSets = [new LiveIedDataSetModel + { + Domain = "LD", LogicalNode = "LLN0", + Reference = "LD/LLN0.DS", + Members = [new LiveIedDataSetMemberModel + { + Reference = Owner, FunctionalConstraint = "ST" + }] + }] + }; + + [Fact] + public void ExactSclTypedValueDirectoryRecoversRealAlreadyObservedReportLeaves() + { + var live = Model([Field("q"), Field("t")]); + var scl = Model([Field("valWTr"), Field("posVal"), + Field("q"), Field("orCat"), Field("orIdent")]); + var set = Assert.Single(live.DataSets); + var row = Assert.Single(IedBrowserPresentation.Details( + new IedBrowserTreeRow("dataset", "DS", "DataSet", 3, set), + live, true, scl).Where(r => r.Kind == "Data")); + Assert.Equal(2, row.ScalarComponents.Count); + Assert.Equal(Owner, IedBrowserLiveValues.CanonicalReference(row.ValueReference)); + var ledger = new IedBrowserLiveValues(); + var token = ledger.BeginAssociation(); + ledger.PublishForAssociation(token, Owner + ".valWTr", "ST", "4", + "", "", "Report"); + ledger.PublishForAssociation(token, Owner + ".posVal", "ST", "4", + "", "", "Report"); + Assert.True(IedBrowserMultiLeafPresentation.TryCompose(row, ledger, + true, out var composed)); + Assert.Equal("valWTr: 4; posVal: 4", composed.Text); + } + + [Fact] + public void SclOnlyGoCbMayNavigateButNotProduceSpeculativeMmsReadTargets() + { + var live = Model([]); + var block = new LiveIedControlBlockModel + { + Name = "gcb", Kind = "GOOSE", DiscoveryStatus = "SCLOnly", Domain = "LD", + LogicalNode = "LLN0", Reference = "LD/LLN0.GO.gcb", + DataSetReference = "LD/LLN0.DS" + }; + var scl = Model([]); + var withGoose = new LiveIedModelDiscoveryDocument + { + LogicalDevices = scl.LogicalDevices, + DataSets = scl.DataSets, + GooseControlBlocks = [block] + }; + var category = Assert.Single(IedBrowserPresentation.Roots(live, + Array.Empty(), sclModel: withGoose) + .Where(r => r.Key == "category/goose")); + var node = Assert.Single(Assert.Single(Assert.Single( + category.GetChildren()).GetChildren()).GetChildren()); + Assert.Empty(IedBrowserReadPlan.Build(live, node, false)); + } +}