Skip to content
Draft
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
10 changes: 8 additions & 2 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<System.Reflection.AssemblyInformationalVersionAttribute>()
.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 += (_, _) =>
{
Expand Down
54 changes: 54 additions & 0 deletions Models/IedBrowserGooseDirectory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using AR.Iec61850.Discovery;

namespace ArIED61850Tester.Models;

/// <summary>
/// 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.
/// </summary>
public static class IedBrowserGooseDirectory
{
public static IReadOnlyList<LiveIedControlBlockModel> Resolve(
LiveIedModelDiscoveryDocument? live,
LiveIedModelDiscoveryDocument? scl)
{
if (live is null) return scl?.GooseControlBlocks ?? [];
var blocks = new List<LiveIedControlBlockModel>(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;
}
}
21 changes: 14 additions & 7 deletions Models/IedBrowserPresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,8 @@ public static class IedBrowserPresentation
public static IReadOnlyList<IedBrowserTreeRow> Roots(
LiveIedModelDiscoveryDocument? model, IReadOnlyList<RcbExportRow> rcbs,
IReadOnlyList<IedBrowserRemoteFile>? 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.
Expand All @@ -305,7 +306,8 @@ IedBrowserTreeRow Category(string name, string key,

var categories = new List<IedBrowserTreeRow>
{
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", () =>
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions Models/IedBrowserReadPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ public static IReadOnlyList<IedBrowserReadTarget> 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)
Expand Down
46 changes: 32 additions & 14 deletions Models/IedBrowserStructuredDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@
// 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<IedBrowserScalarComponent>()
: 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(
Expand Down Expand Up @@ -68,7 +67,8 @@
}

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('/');
Expand Down Expand Up @@ -102,15 +102,33 @@
.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<IedBrowserScalarComponent>();
// 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<IedBrowserScalarComponent>();
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()
Expand Down Expand Up @@ -291,7 +309,7 @@
var hasDirect = matching.attr is not null;
var attr = hasDirect ? matching.attr : groupArray[0].attr;
var descendants = groupArray.Where(x => x.parts.Length > pathIndex + 1).ToArray();
var fullPrefix = IedBrowserLiveValues.CanonicalReference(attr.ObjectReference) +

Check warning on line 312 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 312 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 312 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 312 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 312 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 312 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.
"." + string.Join(".", groupArray[0].parts.Take(pathIndex + 1));
var polar = allowVector
? IedBrowserEngineeringComposite.BuildPolar(
Expand Down
84 changes: 84 additions & 0 deletions tests/ARSAS.Tests/M214GooseSclDirectoryRecoveryTests.cs
Original file line number Diff line number Diff line change
@@ -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<RcbExportRow>(), 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));
}
}
Loading
Loading