From d8dc2d267d17a7ebf186a4d08fcf88e908c0dc3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 06:55:50 +0700 Subject: [PATCH 1/3] feat(identity): exact trusted-SCL IED/LD mapping on pinned smart-discovery baseline --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 112 ++++++++++++ .../Export/AuthoritativeLiveIedSclExporter.cs | 83 ++++++++- .../Scl/Export/LiveIedSclExportModels.cs | 7 + .../TrustedSclIedIdentityMatcherTests.cs | 172 ++++++++++++++++++ 4 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs create mode 100644 tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs new file mode 100644 index 00000000..a3b8918a --- /dev/null +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Xml.Linq; + +namespace AR.Iec61850.Discovery; + +/// +/// Evidence-only IEC 61850 identity reconciliation. A server exposes MMS domains, +/// not a guaranteed physical IED name or LDevice.inst boundary. Only a unique, +/// fully matched engineering SCL topology can promote a provisional identity. +/// No vendor-name heuristic, network call, cached-IP assumption, or model mutation. +/// +public static class TrustedSclIedIdentityMatcher +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveIedIdentity? TryMatch( + XDocument trustedScl, + IEnumerable observedMmsDomains, + string? connectedHost, + string? selectedIedName = null) + { + ArgumentNullException.ThrowIfNull(trustedScl); + ArgumentNullException.ThrowIfNull(observedMmsDomains); + if (trustedScl.Root?.Name != Scl + "SCL") return null; + var observed = observedMmsDomains + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (observed.Count == 0) return null; + + var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); + foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) + { + var name = ((string?)ied.Attribute("name") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name) || + (!string.IsNullOrWhiteSpace(selectedIedName) && + !name.Equals(selectedIedName.Trim(), StringComparison.OrdinalIgnoreCase))) + continue; + + foreach (var ap in ied.Elements(Scl + "AccessPoint")) + { + var apName = ((string?)ap.Attribute("name") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(apName)) continue; + var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray() + ?? Array.Empty(); + if (devices.Length != observed.Count) continue; + + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var invalid = false; + foreach (var ld in devices) + { + var inst = ((string?)ld.Attribute("inst") ?? "").Trim(); + var ldName = ((string?)ld.Attribute("ldName") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(inst) || !IsValidSclIdentifier(inst)) + { + invalid = true; + break; + } + var domain = ldName.Length > 0 ? ldName : name + inst; + if (!observed.Contains(domain) || !aliases.TryAdd(domain,inst)) + { + invalid = true; + break; + } + } + if (invalid || aliases.Count != observed.Count) continue; + + var connected = trustedScl.Root.Element(Scl + "Communication")? + .Descendants(Scl + "ConnectedAP") + .Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.OrdinalIgnoreCase) + && string.Equals((string?)x.Attribute("apName"),apName,StringComparison.OrdinalIgnoreCase)) + .ToArray() ?? Array.Empty(); + if (connected.Length > 1) continue; + var ipValues = connected.SingleOrDefault()? + .Element(Scl + "Address")?.Elements(Scl + "P") + .Where(x => string.Equals((string?)x.Attribute("type"),"IP",StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value.Trim()).Take(2).ToArray() ?? Array.Empty(); + if (ipValues.Length > 1) continue; + var ip = ipValues.SingleOrDefault(); + if (!string.IsNullOrWhiteSpace(ip) && !string.IsNullOrWhiteSpace(connectedHost)) + { + if (!IPAddress.TryParse(ip,out var designIp) || + !IPAddress.TryParse(connectedHost.Trim(),out var observedIp) || + !designIp.Equals(observedIp)) + continue; // never promote a model for a different endpoint + } + matches.Add((name, apName, aliases, ip)); + } + } + // A single IED can have multiple APs and the same LD topology. This + // remains ambiguous until AP is independently selected/proven. + if (matches.Count != 1) return null; + var match=matches[0]; + return new LiveIedIdentity + { + IedName=match.Ied, + Source="TrustedSclExactDomainMatch", + Confidence=LiveIedDiscoveryConfidenceLevel.High, + IsAmbiguous=false, + CandidateNames=[match.Ied], + LogicalDeviceAliases=match.Aliases, + Evidence=[ + $"Trusted SCL IED '{match.Ied}' / AccessPoint '{match.Ap}' matches all {observed.Count} observed MMS domains and unique LDevice instances.", + string.IsNullOrWhiteSpace(match.Ip) ? "No endpoint IP asserted by SCL." : $"SCL endpoint IP {match.Ip}." + ] + }; + } + + private static bool IsValidSclIdentifier(string s) + => s.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.'); +} diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index ccd70779..b92de8a7 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -26,7 +26,14 @@ public static LiveIedSclExportResult WriteFiles( try { var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); - if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) + if (options.VerifiedIdentity is { } verified) + { + if (!string.IsNullOrWhiteSpace(options.IedNameOverride) && + !verified.IedName.Equals(options.IedNameOverride.Trim(), StringComparison.Ordinal)) + throw new InvalidDataException("Caller IED override conflicts with trusted SCL evidence."); + document = ApplyVerifiedIdentity(document, model, verified); + } + else if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) document = ApplyIdentity(document, model, options.IedNameOverride); document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); @@ -99,6 +106,80 @@ public static XDocument ApplyIdentity( return document; } + + /// + /// Normalizes both IED identity and LD instance mappings from verified SCL + /// topology. Physical MMS domain / FCDA evidence remains unchanged. + /// + public static XDocument ApplyVerifiedIdentity( + XDocument generated, + LiveIedModelDiscoveryDocument model, + LiveIedIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + var observed = model.LogicalDevices + .Select(LogicalDeviceDomain) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!identity.Source.Equals("TrustedSclExactDomainMatch", StringComparison.Ordinal) || + identity.Confidence != LiveIedDiscoveryConfidenceLevel.High || + identity.IsAmbiguous || + observed.Count == 0 || + identity.LogicalDeviceAliases.Count != observed.Count || + identity.LogicalDeviceAliases.Keys.Any(x => !observed.Contains(x)) || + identity.LogicalDeviceAliases.Values.Any(x => string.IsNullOrWhiteSpace(x)) || + identity.LogicalDeviceAliases.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count() != observed.Count) + throw new InvalidDataException("SCL identity evidence is not a unique complete MMS-domain mapping."); + + // Existing authoritative normalization proves identity and preserves the + // MMS wire-domain graph. Transform LDevice.inst and FCDA LD references + // together so that a single-domain BCUGEF650 becomes BCUGE/F650. + var document = ApplyIdentity(generated, model, identity.IedName); + var rewrites = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var ied = document.Root?.Element(Scl + "IED") + ?? throw new InvalidDataException("SCL has no unique IED."); + var nodes = ied.Descendants(Scl + "LDevice").ToArray(); + if (nodes.Length != observed.Count) + throw new InvalidDataException("Generated LD set and observed MMS domain set differ."); + + foreach (var ld in nodes) + { + var oldInst = ((string?)ld.Attribute("inst") ?? "").Trim(); + var explicitDomain = ((string?)ld.Attribute("ldName") ?? "").Trim(); + var domain = explicitDomain.Length != 0 ? explicitDomain : identity.IedName + oldInst; + if (!identity.LogicalDeviceAliases.TryGetValue(domain, out var newInst) || + !seen.Add(domain)) + throw new InvalidDataException($"Generated SCL domain '{domain}' is not uniquely matched."); + rewrites.Add(oldInst, newInst); + ld.SetAttributeValue("inst", newInst); + ld.SetAttributeValue("ldName", + domain.Equals(identity.IedName + newInst, StringComparison.OrdinalIgnoreCase) + ? null : domain); + } + if (seen.Count != observed.Count) + throw new InvalidDataException("Generated SCL did not map every observed MMS domain."); + + // FCDA ldInst references refer to LDevice.inst, not the MMS domain. + // Never rewrite references belonging to an explicitly different IED. + foreach (var fcda in ied.Descendants(Scl + "FCDA")) + { + var fcdaIed = ((string?)fcda.Attribute("iedName") ?? "").Trim(); + if (fcdaIed.Length > 0 && + !fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase) && + !fcdaIed.Equals(identity.IedName, StringComparison.OrdinalIgnoreCase)) + continue; + var oldRef = ((string?)fcda.Attribute("ldInst") ?? "").Trim(); + if (rewrites.TryGetValue(oldRef, out var mapped)) + fcda.SetAttributeValue("ldInst", mapped); + if (fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase)) + fcda.SetAttributeValue("iedName", identity.IedName); + } + ValidateIdentity(document, identity.IedName, model); + return document; + } + public static XDocument ApplyReportControlConfiguration( XDocument source, LiveIedModelDiscoveryDocument model, diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs index c6b6ac3f..ad2c7838 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs @@ -15,6 +15,13 @@ public sealed class LiveIedSclExportOptions /// when an implementation uses explicit communication-level LD names. /// public string IedNameOverride { get; init; } = string.Empty; + + /// + /// Optional evidence proved against the exact observed MMS domain set by + /// TrustedSclIedIdentityMatcher. Not a display alias or an IP-based guess. + /// Export fails closed on incomplete/colliding aliases. + /// + public AR.Iec61850.Discovery.LiveIedIdentity? VerifiedIdentity { get; init; } public string IpSubnet { get; init; } = "255.255.255.0"; public string IpGateway { get; init; } = "0.0.0.0"; public string OsiApTitle { get; init; } = string.Empty; diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs new file mode 100644 index 00000000..62274a78 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -0,0 +1,172 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; +using System.Xml.Linq; + +namespace AR.Iec61850.Tests.Discovery; + +public sealed class TrustedSclIedIdentityMatcherTests +{ + private const string SclNs = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void GE_F650_Single_Domain_Uses_SCL_Evidence_Not_Vendor_Suffix_Heuristic() + { + var inferred = LiveIedIdentityResolver.Resolve(["BCUGEF650"], "192.16.1.33"); + Assert.Equal("HostFallback", inferred.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Low,inferred.Confidence); + + var identity = TrustedSclIedIdentityMatcher.TryMatch( + Design("BCUGE", "F650", "192.16.1.33"), ["BCUGEF650"], "192.16.1.33"); + Assert.NotNull(identity); + Assert.Equal("BCUGE",identity.IedName); + Assert.Equal("F650",identity.LogicalDeviceAliases["BCUGEF650"]); + Assert.Equal("TrustedSclExactDomainMatch",identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,identity.Confidence); + } + + [Fact] + public void Verified_F650_Export_RoundTrips_Mms_Domain_And_Fcda_LdInst() + { + var model = Model("IED_192_16_1_33","BCUGEF650"); + var identity = TrustedSclIedIdentityMatcher.TryMatch( + Design("BCUGE","F650","192.16.1.33"),["BCUGEF650"],"192.16.1.33"); + Assert.NotNull(identity); + + var generated = LiveIedSclExporter.BuildDocument(model, + new LiveIedSclExportOptions { SchemaProfile = SclSchemaProfile.Edition1V16 }); + var normalized = AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(generated,model,identity); + XNamespace ns=SclNs; + var ied=Assert.Single(normalized.Root!.Elements(ns+"IED")); + Assert.Equal("BCUGE",(string?)ied.Attribute("name")); + var device=Assert.Single(ied.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)device.Attribute("inst")); + Assert.Null(device.Attribute("ldName")); // implicit MMS domain BCUGEF650 + Assert.Equal("BCUGE",(string?)Assert.Single(normalized.Descendants(ns+"ConnectedAP")).Attribute("iedName")); + var fcda=Assert.Single(normalized.Descendants(ns+"FCDA")); + Assert.Equal("F650",(string?)fcda.Attribute("ldInst")); + } + + [Fact] + public void Explicit_LdName_Remains_Exact_When_Different_From_Implicit_Mms_Domain() + { + var scl=Design("BCUGE","F650","192.16.1.33","CUSTOM_WIRE_DOMAIN"); + var identity=TrustedSclIedIdentityMatcher.TryMatch(scl, + ["CUSTOM_WIRE_DOMAIN"],"192.16.1.33"); + Assert.NotNull(identity); + var model=Model("IED_192_16_1_33","CUSTOM_WIRE_DOMAIN"); + var doc=AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity( + LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()),model,identity); + XNamespace ns=SclNs; + var ld=Assert.Single(doc.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)ld.Attribute("inst")); + Assert.Equal("CUSTOM_WIRE_DOMAIN",(string?)ld.Attribute("ldName")); + Assert.Equal("F650",(string?)Assert.Single(doc.Descendants(ns+"FCDA")).Attribute("ldInst")); + } + + [Fact] + public void Different_Host_Or_Missing_Domain_Or_Wrong_Selected_IED_Fails_Closed() + { + var scl=Design("BCUGE","F650","192.16.1.33"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.34")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650","BCUGECTRL"],"192.16.1.33")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33","OTHER_IED")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["DIFFERENT"],"192.16.1.33")); + } + + [Fact] + public void Ambiguous_Duplicate_AccessPoints_And_Conflicting_Sources_Are_Not_Promoted() + { + XNamespace ns=SclNs; + var scl=Design("BCUGE","F650","192.16.1.33"); + var ied=Assert.Single(scl.Descendants(ns+"IED")); + var ap=Assert.Single(ied.Elements(ns+"AccessPoint")); + var duplicate=new XElement(ap); + duplicate.SetAttributeValue("name","S2"); + ied.Add(duplicate); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33")); + + var model=Model("IED_192_16_1_33","BCUGEF650"); + var unverified=LiveIedIdentityResolver.Resolve(["BCUGEF650"],"192.16.1.33"); + Assert.Throws(() => + AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity( + LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()), + model,unverified)); + } + + [Fact] + public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged() + { + var old=LiveIedIdentityResolver.Resolve( + ["OLSF501LD0","OLSF501CTRL"],"192.0.2.10"); + Assert.Equal("OLSF501",old.IedName); + var other=LiveIedIdentityResolver.Resolve( + ["OCR7SJ8Application","OCR7SJ8CB1","OCR7SJ8Mod2_MU1"], + "192.0.2.12"); + Assert.Equal("OCR7SJ8",other.IedName); + } + + private static XDocument Design(string ied,string inst,string ip,string? ldName=null) + { + XNamespace ns=SclNs; + return new XDocument(new XElement(ns+"SCL", + new XElement(ns+"Communication", + new XElement(ns+"SubNetwork", + new XElement(ns+"ConnectedAP", + new XAttribute("iedName",ied),new XAttribute("apName","S1"), + new XElement(ns+"Address", + new XElement(ns+"P",new XAttribute("type","IP"),ip))))), + new XElement(ns+"IED",new XAttribute("name",ied), + new XElement(ns+"AccessPoint",new XAttribute("name","S1"), + new XElement(ns+"Server", + new XElement(ns+"LDevice", + new XAttribute("inst",inst), + ldName is null ? null : new XAttribute("ldName",ldName), + new XElement(ns+"LN0", + new XAttribute("lnType","LLN0_type")))))))); + } + + private static LiveIedModelDiscoveryDocument Model(string ied,string domain) + => new() + { + Host="192.16.1.33", + IedName=ied, + AccessPointName="S1", + LogicalDevices=[ + new LiveIedLogicalDeviceModel + { + MmsDomain=domain, + Inst=domain, + LogicalNodes=[ + new LiveIedLogicalNodeModel + { + Name="LLN0", + LnClass="LLN0", + ProposedLnTypeId="LN_LLN0_type" + }, + new LiveIedLogicalNodeModel + { + Name="CSWI6", + LnClass="CSWI", + LnInst="6", + ProposedLnTypeId="LN_CSWI6_type" + } + ] + } + ], + DataSets=[ + new LiveIedDataSetModel + { + Reference=$"{domain}/LLN0.Digital", + Domain=domain,LogicalNode="LLN0",Name="Digital", + MemberCount=1, + Members=[ + new LiveIedDataSetMemberModel + { + Index=0,Reference=$"{domain}/CSWI6.Pos.stVal", + FunctionalConstraint="ST" + } + ] + } + ] + }; +} From d34757ee07313c567b452486280280e5d94c7b62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 06:58:01 +0700 Subject: [PATCH 2/3] fix(scl): thread verified IED/LD identity through canonical live export and instance values --- .../Scl/Export/CanonicalLiveIedSclExporter.cs | 42 +++++++++++++------ .../CanonicalLiveIedSclExporterTests.cs | 42 +++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs index 925cbe11..ae31f67b 100644 --- a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs @@ -19,7 +19,8 @@ public static LiveIedSclExportResult WriteFiles( LiveIedCanonicalModel canonical, string sclPath, SclSchemaProfile schemaProfile = SclSchemaProfile.Edition2V31, - string profile = "safe-connection") + string profile = "safe-connection", + LiveIedIdentity? verifiedIdentity = null) { ArgumentNullException.ThrowIfNull(canonical); ValidateCanonicalCommunication(canonical); @@ -41,7 +42,8 @@ public static LiveIedSclExportResult WriteFiles( ? "StationBus" : communication.SubNetworkName.Trim(), IpAddress = communication.Host.Trim(), - IedNameOverride = canonical.IedName, + IedNameOverride = verifiedIdentity?.IedName ?? canonical.IedName, + VerifiedIdentity = verifiedIdentity, IpSubnet = communication.IpSubnet?.Trim() ?? string.Empty, IpGateway = communication.IpGateway?.Trim() ?? string.Empty, OsiApTitle = association.ApTitle.Trim(), @@ -53,10 +55,10 @@ public static LiveIedSclExportResult WriteFiles( }); var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); - ApplyCanonicalCommunication(document, canonical); + ApplyCanonicalCommunication(document, canonical, verifiedIdentity?.IedName); PreserveRuntimeServiceCapacity(document, canonical.Discovery); - ApplyCanonicalInstanceValues(document, canonical); - ValidateRoundTripAssociation(document, canonical); + ApplyCanonicalInstanceValues(document, canonical, verifiedIdentity); + ValidateRoundTripAssociation(document, canonical, verifiedIdentity?.IedName); document.Save(result.SclPath); return result; } @@ -106,11 +108,12 @@ public static void ValidateCanonicalCommunication(LiveIedCanonicalModel canonica throw new InvalidDataException(string.Join(" | ", errors)); } - private static void ApplyCanonicalCommunication(XDocument document, LiveIedCanonicalModel canonical) + private static void ApplyCanonicalCommunication( + XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName) { var connectedAp = document.Descendants(Scl + "ConnectedAP").SingleOrDefault() ?? throw new InvalidDataException("Generated SCL must contain exactly one ConnectedAP."); - connectedAp.SetAttributeValue("iedName", canonical.IedName); + connectedAp.SetAttributeValue("iedName", trustedIedName ?? canonical.IedName); connectedAp.SetAttributeValue("apName", canonical.AccessPointName); var subNetwork = connectedAp.Parent; @@ -163,7 +166,8 @@ public static void PreserveRuntimeServiceCapacity( public static void ApplyCanonicalInstanceValues( XDocument document, - LiveIedCanonicalModel canonical) + LiveIedCanonicalModel canonical, + LiveIedIdentity? verifiedIdentity = null) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(canonical); @@ -212,7 +216,7 @@ public static void ApplyCanonicalInstanceValues( if (logicalNodeModel is null) continue; - var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain); + var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain, verifiedIdentity); if (lDevice is null) continue; @@ -272,9 +276,20 @@ public static void ApplyCanonicalInstanceValues( private static XElement? FindExportedLogicalDevice( XDocument document, LiveIedCanonicalModel canonical, - string domain) + string domain, + LiveIedIdentity? verifiedIdentity) { var normalizedDomain = domain.Trim(); + // A verified engineering source provides the exact LD instance. + // Avoid recomputing it from the canonical *provisional* IED name. + if (verifiedIdentity is not null && + verifiedIdentity.LogicalDeviceAliases.TryGetValue(normalizedDomain,out var trustedInst)) + { + var trusted = document.Descendants(Scl + "LDevice") + .Where(ld => string.Equals((string?)ld.Attribute("inst"),trustedInst,StringComparison.OrdinalIgnoreCase)) + .ToArray(); + return trusted.Length == 1 ? trusted[0] : null; + } var iedName = canonical.IedName.Trim(); var stripped = normalizedDomain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) && normalizedDomain.Length > iedName.Length @@ -462,12 +477,13 @@ private static bool TryFormatScalarValue( } } - private static void ValidateRoundTripAssociation(XDocument document, LiveIedCanonicalModel canonical) + private static void ValidateRoundTripAssociation( + XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName) { var profiles = SclMmsAssociationProfileReader.Read(document); - var remote = profiles.Find(canonical.IedName, canonical.AccessPointName) + var remote = profiles.Find(trustedIedName ?? canonical.IedName, canonical.AccessPointName) ?? throw new InvalidDataException( - $"Generated SCL cannot round-trip its own ConnectedAP '{canonical.IedName}/{canonical.AccessPointName}'."); + $"Generated SCL cannot round-trip its own ConnectedAP '{trustedIedName ?? canonical.IedName}/{canonical.AccessPointName}'."); var canonicalAssociation = canonical.Communication.Association; var roundTripErrors = new List(); diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs index c6d7ed27..ca90f1a1 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -10,6 +10,48 @@ public sealed class CanonicalLiveIedSclExporterTests { private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + [Fact] + public void VerifiedSclIdentity_KeepsAcceptedMmsAssociationWhileMappingLogicalDevice() + { + var canonical = CreateCanonical(); + var observedDomain = canonical.Discovery.LogicalDevices.Single().MmsDomain; + XNamespace ns = Scl; + var trusted = new XDocument(new XElement(ns+"SCL", + new XElement(ns+"Communication",new XElement(ns+"SubNetwork", + new XElement(ns+"ConnectedAP", + new XAttribute("iedName","BCUGE"),new XAttribute("apName","AP1"), + new XElement(ns+"Address", + new XElement(ns+"P",new XAttribute("type","IP"),"10.20.30.40"))))), + new XElement(ns+"IED",new XAttribute("name","BCUGE"), + new XElement(ns+"AccessPoint",new XAttribute("name","AP1"), + new XElement(ns+"Server",new XElement(ns+"LDevice", + new XAttribute("inst","F650"), + new XAttribute("ldName",observedDomain))))))); + var proof = TrustedSclIedIdentityMatcher.TryMatch( + trusted,[observedDomain],"10.20.30.40","BCUGE"); + Assert.NotNull(proof); + + var dir=Path.Combine(Path.GetTempPath(),"ariec-p10-canonical-"+Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var result=CanonicalLiveIedSclExporter.WriteFiles(canonical, + Path.Combine(dir,"BCUGE.iid"),SclSchemaProfile.Edition1V16, + verifiedIdentity:proof); + var doc=XDocument.Load(result.SclPath); + Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"IED")).Attribute("name")); + var ld=Assert.Single(doc.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)ld.Attribute("inst")); + Assert.Equal(observedDomain,(string?)ld.Attribute("ldName")); + Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"ConnectedAP")).Attribute("iedName")); + Assert.NotNull(SclMmsAssociationProfileReader.Read(doc).Find("BCUGE",canonical.AccessPointName)); + } + finally + { + Directory.Delete(dir,true); + } + } + [Fact] public void CanonicalBuilder_Preserves_CaseDistinct_InitialFcLeafEvidence() { From 00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 07:02:54 +0700 Subject: [PATCH 3/3] test(identity): construct canonical fixture with an observed logical device --- .../CanonicalLiveIedSclExporterTests.cs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs index ca90f1a1..1010e7d7 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -13,8 +13,29 @@ public sealed class CanonicalLiveIedSclExporterTests [Fact] public void VerifiedSclIdentity_KeepsAcceptedMmsAssociationWhileMappingLogicalDevice() { - var canonical = CreateCanonical(); - var observedDomain = canonical.Discovery.LogicalDevices.Single().MmsDomain; + var accepted = CreateCanonical(); + const string observedDomain = "IEDLD0"; + var canonical = new LiveIedCanonicalModel + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = "10.20.30.40", IedName = "IED", AccessPointName = "AP1", + LogicalDevices = [ + new LiveIedLogicalDeviceModel + { + MmsDomain = observedDomain, Inst = observedDomain, + LogicalNodes = [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", LnClass = "LLN0", + ProposedLnTypeId = "LN_LLN0" + } + ] + } + ] + }, + Communication = accepted.Communication + }; XNamespace ns = Scl; var trusted = new XDocument(new XElement(ns+"SCL", new XElement(ns+"Communication",new XElement(ns+"SubNetwork",