From 79f9de08b56a7847720d12469a5dadf7d31cc5bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:35:14 +0700 Subject: [PATCH 1/9] fix(scl): resolve same-IED ServerAt chains with fail-closed owner semantics --- .../Scl/SclServerAtModelResolver.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/AR.Iec61850/Scl/SclServerAtModelResolver.cs diff --git a/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs b/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs new file mode 100644 index 00000000..cd1d963c --- /dev/null +++ b/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs @@ -0,0 +1,52 @@ +using System.Xml.Linq; + +namespace AR.Iec61850.Scl; + +/// +/// 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. +/// +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(); + + 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); +} From 8b57df87ffd151ed6696a4b9bb2400a562307538 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:35:25 +0700 Subject: [PATCH 2/9] fix(scl): preserve referenced Server model for ServerAt endpoint projections --- .../Scl/Workspace/SclWorkspaceService.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs index 1992f3b6..1f602e5a 100644 --- a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs +++ b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs @@ -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()) From f111948f7103e15fe0be1e3e0fec84ffc20a7f7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:35:38 +0700 Subject: [PATCH 3/9] fix(scl): recognize valid ServerAt in engineering profile; flag invalid references --- .../SclEngineeringProfileBuilder.cs | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs b/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs index 1798bfe9..30fe9d6e 100644 --- a/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs +++ b/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs @@ -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 { @@ -104,13 +104,18 @@ private static IEnumerable 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 }; } } @@ -230,6 +235,7 @@ private static HashSet ParseServiceDeclarations(XElement root) } private static void AddStaticFindings( + XElement root, SclDocument document, IReadOnlyList accessPoints, IReadOnlyList logicalDevices, @@ -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)); From ebffc41c9da7a1ef32d19da452b21a489ffd9fb0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:36:03 +0700 Subject: [PATCH 4/9] test(scl): Siemens-style J Server plus F ServerAt dual endpoint regression --- .../Scl/StationServerAtRegressionTests.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs diff --git a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs new file mode 100644 index 00000000..8c2a3d4c --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs @@ -0,0 +1,116 @@ +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 Dangling_ServerAt_Fails_Closed_Without_Fabricating_Mms_Model_Or_Losing_Ip() + { + var broken = StationFixture().Replace( + "", + "", + 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"); + } + + private static string StationFixture() => """ + +
+ + + +

192.0.2.11

+
+
+ + +

198.51.100.12

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + """; +} From cee8021180db737ae9fee2b7ec6d67adf43a7d03 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:48:51 +0700 Subject: [PATCH 5/9] fix(scl): resolve ServerAt owner for exact MMS domain inventory on selected AP --- src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs index 0643b4b9..e1d35132 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs @@ -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); } From 90ec4f215899bd35a1f26001b9987d04e5a1f157 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:49:02 +0700 Subject: [PATCH 6/9] fix(scl): carry ServerAt model through SCL-assisted initial FC-read projection --- .../Scl/SclInitialFcReadDesignBuilder.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs b/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs index ed5a8f7a..b78c7db4 100644 --- a/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs +++ b/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs @@ -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(StringComparer.Ordinal); foreach (var lDevice in server.Elements().Where(element => Is(element, "LDevice"))) @@ -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)) From 0f32a71d92720430a209063a43f02b6033fdc4ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:49:23 +0700 Subject: [PATCH 7/9] test(scl): ServerAt must preserve both MMS domains and SCL-assisted initial FC read --- .../Scl/StationServerAtRegressionTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs index 8c2a3d4c..a4960e55 100644 --- a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs @@ -52,6 +52,30 @@ public void Engineering_Profile_Valid_ServerAt_Is_Not_Reported_As_Missing_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() { @@ -68,6 +92,11 @@ public void Dangling_ServerAt_Fails_Closed_Without_Fabricating_Mms_Model_Or_Losi 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() => """ From e45259b710e5e6962203428abbc7fa063a25f52d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:49:35 +0700 Subject: [PATCH 8/9] test(scl): import exact domain and initial-read authority namespace --- tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs index a4960e55..cecab309 100644 --- a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Scl; using AR.Iec61850.Scl.Engineering; using AR.Iec61850.Scl.Workspace; From 352c81e6a798635c6addcee0683235ca87ad416d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:54:09 +0700 Subject: [PATCH 9/9] test(scl): retain no-server fail-closed check with ServerAt-aware diagnostic text --- tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs index 6110c990..5ec16bd8 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs @@ -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]