Skip to content
Closed
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
29 changes: 26 additions & 3 deletions src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public SclEngineeringProfile Build(XDocument xdoc, string sourceName = "")
LogicalDeviceCount = logicalDevices.Count(ld => Same(ld.IedName, i.Name))
}).ToList();

AddStaticFindings(scl, accessPoints, logicalDevices, logicalNodes, externalReferences, findings);
AddStaticFindings(root, scl, accessPoints, logicalDevices, logicalNodes, externalReferences, findings);

var capabilities = new SclEngineeringCapabilityMatrix
{
Expand Down Expand Up @@ -104,13 +104,18 @@ private static IEnumerable<SclEngineeringAccessPoint> ParseAccessPoints(XElement
foreach (var ap in ied.Elements().Where(e => Is(e, "AccessPoint")))
{
var server = ap.Elements().FirstOrDefault(e => Is(e, "Server"));
var owner = server is null
? SclServerAtModelResolver.ResolveOwner(ied, ap)
: ap;
var effectiveServer = owner?.Elements().FirstOrDefault(e => Is(e, "Server"));
yield return new SclEngineeringAccessPoint
{
IedName = iedName,
Name = Attr(ap, "name"),
// HasServer describes a physically declared Server, not ServerAt.
HasServer = server is not null,
Router = Attr(ap, "router"),
LogicalDeviceCount = server?.Elements().Count(e => Is(e, "LDevice")) ?? 0
LogicalDeviceCount = effectiveServer?.Elements().Count(e => Is(e, "LDevice")) ?? 0
};
}
}
Expand Down Expand Up @@ -230,6 +235,7 @@ private static HashSet<string> ParseServiceDeclarations(XElement root)
}

private static void AddStaticFindings(
XElement root,
SclDocument document,
IReadOnlyList<SclEngineeringAccessPoint> accessPoints,
IReadOnlyList<SclEngineeringLogicalDevice> logicalDevices,
Expand All @@ -244,7 +250,24 @@ private static void AddStaticFindings(
findings.Add(Finding("High", "SCL_NO_SERVER_MODEL", "No Server/LDevice model was found. The file cannot seed MMS model discovery or simulator model generation."));

foreach (var ap in accessPoints.Where(a => !a.HasServer))
findings.Add(Finding("Warning", "SCL_ACCESS_POINT_WITHOUT_SERVER", $"AccessPoint {ap.IedName}/{ap.Name} has no Server element.", $"{ap.IedName}/{ap.Name}"));
{
var iedElement = root.Elements().FirstOrDefault(e =>
Is(e, "IED") && Same(Attr(e, "name"), ap.IedName));
var apElement = iedElement?.Elements().FirstOrDefault(e =>
Is(e, "AccessPoint") && Same(Attr(e, "name"), ap.Name));
var hasServerAt = apElement?.Elements().Any(e => Is(e, "ServerAt")) == true;
if (hasServerAt && iedElement is not null && apElement is not null &&
SclServerAtModelResolver.ResolveOwner(iedElement, apElement) is not null)
continue; // Valid ServerAt is not a broken/missing Server.

findings.Add(hasServerAt
? Finding("High", "SCL_SERVER_AT_UNRESOLVED",
$"AccessPoint {ap.IedName}/{ap.Name} has a dangling, ambiguous or cyclic ServerAt reference.",
$"{ap.IedName}/{ap.Name}")
: Finding("Warning", "SCL_ACCESS_POINT_WITHOUT_SERVER",
$"AccessPoint {ap.IedName}/{ap.Name} has no Server or ServerAt element.",
$"{ap.IedName}/{ap.Name}"));
}

foreach (var ln in logicalNodes.Where(l => string.IsNullOrWhiteSpace(l.LnType)))
findings.Add(Finding("Warning", "SCL_LN_TYPE_MISSING", $"Logical node {ln.Reference} has no lnType binding.", ln.Reference));
Expand Down
5 changes: 3 additions & 2 deletions src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,11 @@ public static SclMmsDomainInventory Read(XDocument document, string iedName, str
return Build(canonicalIedName, accessPointName, domains, errors, warnings);
}

var server = accessPoint.Elements().FirstOrDefault(element => Is(element, "Server"));
var serverOwner = SclServerAtModelResolver.ResolveOwner(ied, accessPoint);
var server = serverOwner?.Elements().FirstOrDefault(element => Is(element, "Server"));
if (server is null)
{
errors.Add($"SCL IED '{canonicalIedName}' AccessPoint '{accessPointName}' has no direct Server model.");
errors.Add($"SCL IED '{canonicalIedName}' AccessPoint '{accessPointName}' has no resolvable Server/ServerAt model.");
return Build(canonicalIedName, accessPointName, domains, errors, warnings);
}

Expand Down
17 changes: 16 additions & 1 deletion src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ public static SclInitialFcReadDesign Read(XDocument document, string iedName, st
var selectedAp = selectedIed.Elements()
.Where(element => Is(element, "AccessPoint"))
.First(element => string.Equals(Attr(element, "name"), accessPointName, StringComparison.Ordinal));
var server = selectedAp.Elements().First(element => Is(element, "Server"));
// Initial FC-read must use the same canonical ServerAt model authority as
// offline browsing. Keep the selected AP identity for its own MMS address.
var serverOwner = SclServerAtModelResolver.ResolveOwner(selectedIed, selectedAp);
var server = serverOwner?.Elements().FirstOrDefault(element => Is(element, "Server"))
?? throw new InvalidDataException("Selected AccessPoint has no resolvable Server/ServerAt model.");

var domainByInst = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var lDevice in server.Elements().Where(element => Is(element, "LDevice")))
Expand Down Expand Up @@ -107,6 +111,17 @@ public static SclInitialFcReadDesign Read(XDocument document, string iedName, st
continue;
}

var scopedSelectedAp = otherIed.Elements().First(element =>
Is(element, "AccessPoint") &&
string.Equals(Attr(element, "name"), accessPointName, StringComparison.Ordinal));
if (!scopedSelectedAp.Elements().Any(element => Is(element, "Server")))
{
// Only the disposable projection receives a cloned server. No source
// SCD change, no second server address, no cross-IED inheritance.
scopedSelectedAp.Elements().Where(element => Is(element, "ServerAt")).Remove();
scopedSelectedAp.AddFirst(new XElement(server));
}

foreach (var otherAp in otherIed.Elements().Where(element => Is(element, "AccessPoint")).ToArray())
{
if (!string.Equals(Attr(otherAp, "name"), accessPointName, StringComparison.Ordinal))
Expand Down
52 changes: 52 additions & 0 deletions src/AR.Iec61850/Scl/SclServerAtModelResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Xml.Linq;

namespace AR.Iec61850.Scl;

/// <summary>
/// Resolves an IEC 61850 ServerAt reference within the same IED. This is a
/// model reference, not an alternate MMS address. Fail closed for dangling,
/// ambiguous, or circular references; never bind another IED's server.
/// </summary>
internal static class SclServerAtModelResolver
{
public static XElement? ResolveOwner(XElement ied, XElement selectedAccessPoint)
{
var accessPoints = ied.Elements()
.Where(element => Is(element, "AccessPoint"))
.ToArray();
var visited = new HashSet<XElement>();

var current = selectedAccessPoint;
while (visited.Add(current))
{
if (current.Elements().Any(element => Is(element, "Server")))
return current;

var references = current.Elements()
.Where(element => Is(element, "ServerAt"))
.ToArray();
if (references.Length != 1)
return null;

var referencedName = ((string?)references[0].Attribute("apName"))?.Trim();
if (string.IsNullOrWhiteSpace(referencedName))
return null;

var matches = accessPoints
.Where(element => string.Equals(
((string?)element.Attribute("name"))?.Trim(),
referencedName,
StringComparison.OrdinalIgnoreCase))
.ToArray();
if (matches.Length != 1)
return null;

current = matches[0];
}

return null; // ServerAt cycle
}

private static bool Is(XElement element, string localName)
=> element.Name.LocalName.Equals(localName, StringComparison.Ordinal);
}
19 changes: 19 additions & 0 deletions src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,25 @@ private static LiveIedModelDiscoveryDocument BuildDesignModel(

if (!string.IsNullOrWhiteSpace(accessPointName))
{
var selectedAccessPoint = selectedIed.Elements()
.FirstOrDefault(x => Is(x, "AccessPoint") && Same(Attr(x, "name"), accessPointName));
if (selectedAccessPoint is not null &&
!selectedAccessPoint.Elements().Any(x => Is(x, "Server")))
{
// ServerAt points at another AP's server model, while this AP keeps
// its own Communication/ConnectedAP address. Materialize that model
// ONLY in the isolated projection (never modify the source SCD).
// This allows an AP like Siemens 7SX85/F -> J to retain LD/LN,
// DataSets and RCBs without misrepresenting its MMS endpoint.
var owner = SclServerAtModelResolver.ResolveOwner(selectedIed, selectedAccessPoint);
var server = owner?.Elements().FirstOrDefault(x => Is(x, "Server"));
if (server is not null)
{
selectedAccessPoint.Elements().Where(x => Is(x, "ServerAt")).Remove();
selectedAccessPoint.AddFirst(new XElement(server));
}
}

foreach (var otherAccessPoint in selectedIed.Elements()
.Where(x => Is(x, "AccessPoint") && !Same(Attr(x, "name"), accessPointName))
.ToArray())
Expand Down
2 changes: 1 addition & 1 deletion tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public void DomainInventoryReader_Fails_Closed_When_Selected_AccessPoint_Has_No_

Assert.False(inventory.IsSuccess);
Assert.Empty(inventory.ExpectedDomains);
Assert.Contains(inventory.Errors, error => error.Contains("no direct Server", StringComparison.Ordinal));
Assert.Contains(inventory.Errors, error => error.Contains("no resolvable Server/ServerAt model", StringComparison.Ordinal));
}

[Fact]
Expand Down
146 changes: 146 additions & 0 deletions tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using AR.Iec61850.Scl;
using AR.Iec61850.Scl.Engineering;
using AR.Iec61850.Scl.Workspace;

namespace AR.Iec61850.Tests.Scl;

public sealed class StationServerAtRegressionTests
{
[Fact]
public void Parse_SiemensStyle_7Sx85_ServerAt_Preserves_Model_On_Both_Mms_Endpoints()
{
// Sanitized topology derived from a real Siemens station SCD.
// Do not commit the customer's exported SCD or network configuration.
var document = new SclWorkspaceService().Parse(StationFixture(), "station.scd");

Assert.Equal(2, document.MmsEndpoints.Count);
Assert.Equal(2, document.Ieds.Count);

var j = document.Ieds.Single(ied => ied.AccessPointName == "J");
var f = document.Ieds.Single(ied => ied.AccessPointName == "F");

Assert.Equal("192.0.2.11", j.PreferredEndpoint!.IpAddress);
Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress);
Assert.Equal(102, j.PreferredEndpoint.Port);
Assert.Equal(102, f.PreferredEndpoint.Port);
Assert.False(j.RequiresEndpointBinding);
Assert.False(f.RequiresEndpointBinding);
Assert.True(j.CanBrowseOffline);
Assert.True(f.CanBrowseOffline);
Assert.Single(j.DesignModel.LogicalDevices);
Assert.Single(f.DesignModel.LogicalDevices);
Assert.Equal(j.DesignModel.LogicalDevices[0].MmsDomain, f.DesignModel.LogicalDevices[0].MmsDomain);
Assert.Equal("GR_X_7SX85Application", f.DesignModel.LogicalDevices[0].MmsDomain);
Assert.Equal("F", f.DesignModel.AccessPointName);
Assert.Equal("198.51.100.12", f.DesignModel.Host);
Assert.Single(f.DataSets);
Assert.Single(f.ReportControls);
Assert.DoesNotContain(f.Findings, finding => finding.Code == "SCL_ACCESS_POINT_WITHOUT_SERVER");
Assert.DoesNotContain(f.Findings, finding => finding.Code == "SCL_SERVER_AT_UNRESOLVED");
}

[Fact]
public void Engineering_Profile_Valid_ServerAt_Is_Not_Reported_As_Missing_Server()
{
var profile = new SclEngineeringProfileBuilder().Parse(StationFixture(), "station.scd");

Assert.Equal(2, profile.AccessPoints.Count);
var f = profile.AccessPoints.Single(ap => ap.Name == "F");
Assert.False(f.HasServer); // Not a physical Server: references J via ServerAt.
Assert.Equal(1, f.LogicalDeviceCount);
Assert.DoesNotContain(profile.Findings, item =>
item.Code == "SCL_ACCESS_POINT_WITHOUT_SERVER" ||
item.Code == "SCL_SERVER_AT_UNRESOLVED");
}

[Fact]
public void SclAssisted_Preparation_Resolves_F_ServerAt_To_J_Domains_And_Fc_Roots()
{
var xml = StationFixture();
var domainsJ = SclMmsDomainInventoryReader.Read(xml, "GR_X_7SX85", "J");
var domainsF = SclMmsDomainInventoryReader.Read(xml, "GR_X_7SX85", "F");

Assert.True(domainsJ.IsSuccess);
Assert.True(domainsF.IsSuccess);
Assert.Equal(domainsJ.ExpectedDomains, domainsF.ExpectedDomains);
Assert.Equal("F", domainsF.AccessPointName);
Assert.Single(domainsF.ExpectedDomains);

var initialJ = SclInitialFcReadDesignBuilder.Read(xml, "GR_X_7SX85", "J");
var initialF = SclInitialFcReadDesignBuilder.Read(xml, "GR_X_7SX85", "F");
Assert.True(initialJ.IsSuccess, string.Join(" | ", initialJ.Errors));
Assert.True(initialF.IsSuccess, string.Join(" | ", initialF.Errors));
Assert.Equal("F", initialF.Model.AccessPointName);
Assert.Equal(initialJ.Model.LogicalDevices.Count, initialF.Model.LogicalDevices.Count);
Assert.Equal(initialJ.Model.DataSets.Count, initialF.Model.DataSets.Count);
Assert.Equal(initialJ.Model.ReportControls.Count, initialF.Model.ReportControls.Count);
Assert.Single(initialF.Model.LogicalDevices);
}

[Fact]
public void Dangling_ServerAt_Fails_Closed_Without_Fabricating_Mms_Model_Or_Losing_Ip()
{
var broken = StationFixture().Replace(
"<ServerAt apName=\"J\" />",
"<ServerAt apName=\"NOT_PRESENT\" />",
StringComparison.Ordinal);
var document = new SclWorkspaceService().Parse(broken, "dangling.scd");

var j = document.Ieds.Single(ied => ied.AccessPointName == "J");
var f = document.Ieds.Single(ied => ied.AccessPointName == "F");
Assert.True(j.CanBrowseOffline);
Assert.False(f.CanBrowseOffline);
Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress);
Assert.Contains(document.Findings, finding =>
finding.Code == "SCL_SERVER_AT_UNRESOLVED" && finding.Severity == "High");
var invalidDomains = SclMmsDomainInventoryReader.Read(broken, "GR_X_7SX85", "F");
var invalidInitial = SclInitialFcReadDesignBuilder.Read(broken, "GR_X_7SX85", "F");
Assert.False(invalidDomains.IsSuccess);
Assert.False(invalidInitial.IsSuccess);
Assert.Empty(invalidDomains.ExpectedDomains);
}

private static string StationFixture() => """
<SCL xmlns="http://www.iec.ch/61850/2003/SCL" version="2007" revision="C">
<Header id="SanitizedStation" version="1" revision="1"/>
<Communication>
<SubNetwork name="Station_J" type="8-MMS">
<ConnectedAP iedName="GR_X_7SX85" apName="J">
<Address><P type="IP">192.0.2.11</P></Address>
</ConnectedAP>
</SubNetwork>
<SubNetwork name="Station_F" type="8-MMS">
<ConnectedAP iedName="GR_X_7SX85" apName="F">
<Address><P type="IP">198.51.100.12</P></Address>
</ConnectedAP>
</SubNetwork>
</Communication>
<IED name="GR_X_7SX85" manufacturer="SIEMENS" type="7SX85">
<AccessPoint name="J">
<Server>
<LDevice inst="Application">
<LN0 lnClass="LLN0" lnType="LLN0Type">
<DataSet name="Indications">
<FCDA ldInst="Application" lnClass="XCBR" lnInst="1" doName="Pos" fc="ST"/>
</DataSet>
<ReportControl name="Rpt_ind" datSet="Indications" buffered="true" confRev="1">
<TrgOps dchg="true" gi="true"/>
<RptEnabled max="2"/>
</ReportControl>
</LN0>
<LN lnClass="XCBR" inst="1" lnType="XCBRType"/>
</LDevice>
</Server>
</AccessPoint>
<AccessPoint name="F">
<ServerAt apName="J" />
</AccessPoint>
</IED>
<DataTypeTemplates>
<LNodeType id="LLN0Type" lnClass="LLN0"/>
<LNodeType id="XCBRType" lnClass="XCBR"><DO name="Pos" type="PosType"/></LNodeType>
<DOType id="PosType" cdc="DPC"><DA name="stVal" bType="Dbpos" fc="ST"/></DOType>
</DataTypeTemplates>
</SCL>
""";
}