From 6f227b019ef37ef50feefb44059d23596bb03d84 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:13:08 +0700 Subject: [PATCH 1/9] Allow authoritative IED identity override for live SCL export --- src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs index 3a233df..69dff6a 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs @@ -9,6 +9,12 @@ public sealed class LiveIedSclExportOptions public SclSchemaProfile SchemaProfile { get; init; } = SclSchemaProfile.Edition2V31; public string SubNetworkName { get; init; } = "StationBus"; public string IpAddress { get; init; } = string.Empty; + /// + /// Authoritative SCL IED identity supplied by the caller. MMS exposes Logical Device + /// domain names, which are not a reliable source for recovering the enclosing IED name + /// when an implementation uses explicit communication-level LD names. + /// + public string IedNameOverride { get; init; } = string.Empty; 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; From 54d8b36674037e8ff09673774d8b3c2dc4ee2163 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:14:09 +0700 Subject: [PATCH 2/9] Add regression coverage for authoritative IED identity --- .../Scl/LiveIedSclIedIdentityTests.cs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs diff --git a/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs new file mode 100644 index 0000000..cff16d9 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs @@ -0,0 +1,130 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; +using System.Xml.Linq; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class LiveIedSclIedIdentityTests +{ + [Fact] + public void Exporter_Uses_Authoritative_Ied_Name_And_Preserves_Explicit_Mms_Domains() + { + var model = new LiveIedModelDiscoveryDocument + { + Host = "1.110.1.1", + // Reproduces the unsafe legacy heuristic: the first MMS domain was copied as IED name. + IedName = "OCR7SJ8Application", + AccessPointName = "AP1", + LogicalDevices = + [ + LogicalDevice("OCR7SJ8Application"), + LogicalDevice("OCR7SJ8CB1") + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "OCR7SJ8Application/LLN0.DataSet", + Domain = "OCR7SJ8Application", + LogicalNode = "LLN0", + Name = "DataSet", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = "OCR7SJ8CB1/XCBR1.Pos.stVal", + FunctionalConstraint = "ST" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "OCR7SJ8Application/LLN0.RP.urcbD0101", + Domain = "OCR7SJ8Application", + LogicalNode = "LLN0", + Name = "urcbD0101", + Buffered = false, + DataSetReference = "OCR7SJ8Application/LLN0.DataSet", + ConfRev = "1" + } + ] + }; + + var document = LiveIedSclExporter.BuildDocument( + model, + new LiveIedSclExportOptions + { + Profile = "full-model", + SchemaProfile = SclSchemaProfile.Edition1V16, + IedNameOverride = "OCR7SJ8Mod2", + IpAddress = "1.110.1.1" + }); + + var root = Assert.IsType(document.Root); + var ns = root.Name.Namespace; + var ied = Assert.Single(root.Elements(ns + "IED")); + Assert.Equal("OCR7SJ8Mod2", (string?)ied.Attribute("name")); + Assert.Equal("OCR7SJ8Mod2", (string?)root.Descendants(ns + "ConnectedAP").Single().Attribute("iedName")); + + var logicalDevices = root.Descendants(ns + "LDevice").ToArray(); + Assert.Equal(2, logicalDevices.Length); + Assert.Contains(logicalDevices, ld => + (string?)ld.Attribute("inst") == "OCR7SJ8Application" && + (string?)ld.Attribute("ldName") == "OCR7SJ8Application"); + Assert.Contains(logicalDevices, ld => + (string?)ld.Attribute("inst") == "OCR7SJ8CB1" && + (string?)ld.Attribute("ldName") == "OCR7SJ8CB1"); + + var report = Assert.Single(root.Descendants(ns + "ReportControl")); + Assert.Equal("urcbD0101", (string?)report.Attribute("name")); + var fcda = Assert.Single(root.Descendants(ns + "FCDA")); + Assert.Equal("OCR7SJ8CB1", (string?)fcda.Attribute("ldInst")); + Assert.DoesNotContain("OCR7SJ8Mod2OCR7SJ8Application", document.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Exporter_Keeps_Implicit_Product_Naming_When_Domain_Starts_With_Ied_Name() + { + var model = new LiveIedModelDiscoveryDocument + { + IedName = "IED1", + LogicalDevices = [LogicalDevice("IED1PROT")] + }; + + var document = LiveIedSclExporter.BuildDocument(model, new LiveIedSclExportOptions()); + var root = Assert.IsType(document.Root); + var ns = root.Name.Namespace; + var logicalDevice = Assert.Single(root.Descendants(ns + "LDevice")); + + Assert.Equal("PROT", (string?)logicalDevice.Attribute("inst")); + Assert.Null(logicalDevice.Attribute("ldName")); + } + + private static LiveIedLogicalDeviceModel LogicalDevice(string domain) + => new() + { + MmsDomain = domain, + Inst = domain, + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", + LnClass = "LLN0", + ProposedLnTypeId = $"LN_{domain}_LLN0" + }, + new LiveIedLogicalNodeModel + { + Name = "XCBR1", + LnClass = "XCBR", + LnInst = "1", + ProposedLnTypeId = $"LN_{domain}_XCBR1" + } + ] + }; +} From 54f892389652e8bf4c2a775ad5349533d785f382 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:14:49 +0700 Subject: [PATCH 3/9] Add one-time live SCL identity patch workflow --- .../apply-live-scl-ied-identity-temp.yml | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .github/workflows/apply-live-scl-ied-identity-temp.yml diff --git a/.github/workflows/apply-live-scl-ied-identity-temp.yml b/.github/workflows/apply-live-scl-ied-identity-temp.yml new file mode 100644 index 0000000..30aa8be --- /dev/null +++ b/.github/workflows/apply-live-scl-ied-identity-temp.yml @@ -0,0 +1,117 @@ +name: Temporary apply live SCL IED identity fix + +on: + push: + branches: [ fix/live-scl-ied-identity ] + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/live-scl-ied-identity + fetch-depth: 0 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Apply exporter identity patch + shell: python + run: | + from pathlib import Path + + path = Path('src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs') + text = path.read_text(encoding='utf-8') + + old = 'EffectiveIedName(model)' + if text.count(old) != 4: + raise SystemExit(f'Expected 4 EffectiveIedName(model) calls, found {text.count(old)}') + text = text.replace(old, 'EffectiveIedName(model, options)', 3) + + old_map = 'StripIedNamePrefix(domain, model.IedName)' + new_map = 'StripIedNamePrefix(domain, EffectiveIedName(model, options))' + if old_map not in text: + raise SystemExit('Logical-device identity mapping anchor not found') + text = text.replace(old_map, new_map, 1) + + old_ld = ' var lDevice = new XElement(Scl + "LDevice", new XAttribute("inst", sclLdInst));' + new_ld = ''' var effectiveIedName = EffectiveIedName(model, options); + var implicitCommunicationName = $"{effectiveIedName}{sclLdInst}"; + var explicitCommunicationName = !ldDomain.Equals(implicitCommunicationName, StringComparison.OrdinalIgnoreCase) + ? new XAttribute("ldName", ldDomain) + : null; + var lDevice = new XElement( + Scl + "LDevice", + new XAttribute("inst", sclLdInst), + explicitCommunicationName);''' + if old_ld not in text: + raise SystemExit('LDevice construction anchor not found') + text = text.replace(old_ld, new_ld, 1) + + old_reconstructed = ''' private static string ReconstructedLogicalDeviceReference( + LiveIedModelDiscoveryDocument model, + LiveIedSclBuildContext context, + string domain) + { + var sclLdInst = SclLogicalDeviceInstByDomain(context, domain); + return $"{EffectiveIedName(model)}{sclLdInst}"; + }''' + new_reconstructed = ''' private static string ReconstructedLogicalDeviceReference( + LiveIedModelDiscoveryDocument model, + LiveIedSclBuildContext context, + string domain) + { + _ = model; + _ = context; + // The MMS domain is the communication-level Logical Device name. It can be + // explicitly configured through LDevice.ldName and must not be reconstructed + // by concatenating an inferred IED name with an arbitrary LD instance. + return domain.Trim(); + }''' + if old_reconstructed not in text: + raise SystemExit('Reconstructed Logical Device reference anchor not found') + text = text.replace(old_reconstructed, new_reconstructed, 1) + + old_effective = ''' private static string EffectiveIedName(LiveIedModelDiscoveryDocument model) + => string.IsNullOrWhiteSpace(model.IedName) ? "LIVE_IED" : SafeXmlName(model.IedName);''' + new_effective = ''' private static string EffectiveIedName( + LiveIedModelDiscoveryDocument model, + LiveIedSclExportOptions options) + { + var authoritative = string.IsNullOrWhiteSpace(options.IedNameOverride) + ? model.IedName + : options.IedNameOverride; + return string.IsNullOrWhiteSpace(authoritative) ? "LIVE_IED" : SafeXmlName(authoritative); + }''' + if old_effective not in text: + raise SystemExit('Effective IED name method anchor not found') + text = text.replace(old_effective, new_effective, 1) + + path.write_text(text, encoding='utf-8') + + - name: Verify focused identity contracts + shell: bash + run: | + grep -q 'IedNameOverride' src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs + grep -q 'new XAttribute("ldName", ldDomain)' src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs + grep -q 'return domain.Trim();' src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs + grep -q 'OCR7SJ8Mod2' tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs + + - name: Test engine + run: dotnet test ARIEC61850.sln -c Release --no-restore + + - name: Commit source and remove temporary workflow + shell: bash + run: | + git rm .github/workflows/apply-live-scl-ied-identity-temp.yml .github/live-scl-ied-identity-trigger.txt + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs + git commit -m "Preserve authoritative IED identity in live SCL export" + git push origin HEAD:fix/live-scl-ied-identity From 7fafd6c88e7703e5d9f7c65f8f0847765edd0b87 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:15:02 +0700 Subject: [PATCH 4/9] Trigger live SCL identity patch --- .github/live-scl-ied-identity-trigger.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/live-scl-ied-identity-trigger.txt diff --git a/.github/live-scl-ied-identity-trigger.txt b/.github/live-scl-ied-identity-trigger.txt new file mode 100644 index 0000000..095d898 --- /dev/null +++ b/.github/live-scl-ied-identity-trigger.txt @@ -0,0 +1 @@ +apply From efd9bda3b5d9a918117f68c4ee4c9677a7639e49 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:16:04 +0700 Subject: [PATCH 5/9] Run live SCL identity patch --- .github/live-scl-ied-identity-trigger.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/live-scl-ied-identity-trigger.txt b/.github/live-scl-ied-identity-trigger.txt index 095d898..6bfbd74 100644 --- a/.github/live-scl-ied-identity-trigger.txt +++ b/.github/live-scl-ied-identity-trigger.txt @@ -1 +1 @@ -apply +apply-2 From ee5b44ca6af75a42a8005d767550e94fb0f2db28 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:21:59 +0700 Subject: [PATCH 6/9] Add authoritative identity wrapper for live SCL export --- .../Export/AuthoritativeLiveIedSclExporter.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs new file mode 100644 index 0000000..499a99a --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -0,0 +1,156 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Scl.Export; + +/// +/// Exports a live-discovery model while keeping the physical IED identity separate from +/// communication-level MMS Logical Device domain names. +/// +public static class AuthoritativeLiveIedSclExporter +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveIedSclExportResult WriteFiles( + LiveIedModelDiscoveryDocument model, + string sclPath, + LiveIedSclExportOptions? options = null) + { + ArgumentNullException.ThrowIfNull(model); + options ??= new LiveIedSclExportOptions(); + + var result = LiveIedSclExporter.WriteFiles(model, sclPath, options); + if (string.IsNullOrWhiteSpace(options.IedNameOverride)) + return result; + + var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); + ApplyIdentity(document, model, options.IedNameOverride); + document.Save(result.SclPath); + return result; + } + + public static XDocument ApplyIdentity( + XDocument source, + LiveIedModelDiscoveryDocument model, + string authoritativeIedName) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(model); + if (string.IsNullOrWhiteSpace(authoritativeIedName)) + throw new ArgumentException("Authoritative IED name is empty.", nameof(authoritativeIedName)); + + var document = new XDocument(source); + var root = document.Root ?? throw new InvalidDataException("Generated SCL document has no root element."); + var safeIedName = SafeXmlName(authoritativeIedName); + + var ied = root.Elements(Scl + "IED").SingleOrDefault() + ?? throw new InvalidDataException("Generated live SCL must contain exactly one IED element."); + ied.SetAttributeValue("name", safeIedName); + + foreach (var connectedAp in root.Descendants(Scl + "ConnectedAP")) + connectedAp.SetAttributeValue("iedName", safeIedName); + + var header = root.Element(Scl + "Header"); + header?.SetAttributeValue("id", $"{safeIedName}_GENERATED"); + + var logicalDevices = ied.Descendants(Scl + "LDevice").ToArray(); + var unmatchedDomains = new HashSet( + model.LogicalDevices + .Select(LogicalDeviceDomain) + .Where(domain => !string.IsNullOrWhiteSpace(domain)), + StringComparer.OrdinalIgnoreCase); + + foreach (var logicalDevice in logicalDevices) + { + var inst = ((string?)logicalDevice.Attribute("inst") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(inst)) + continue; + + var domain = MatchMmsDomain(inst, model.IedName, unmatchedDomains); + if (string.IsNullOrWhiteSpace(domain)) + continue; + + unmatchedDomains.Remove(domain); + var implicitName = $"{safeIedName}{inst}"; + logicalDevice.SetAttributeValue( + "ldName", + domain.Equals(implicitName, StringComparison.OrdinalIgnoreCase) ? null : domain); + } + + ValidateIdentity(document, safeIedName, model); + return document; + } + + private static string MatchMmsDomain( + string generatedInst, + string previousIedName, + IReadOnlySet domains) + { + var direct = domains.FirstOrDefault(domain => + domain.Equals(generatedInst, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(direct)) + return direct; + + var previousImplicit = $"{previousIedName?.Trim()}{generatedInst}"; + var implicitMatch = domains.FirstOrDefault(domain => + domain.Equals(previousImplicit, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(implicitMatch)) + return implicitMatch; + + return string.Empty; + } + + private static void ValidateIdentity( + XDocument document, + string authoritativeIedName, + LiveIedModelDiscoveryDocument model) + { + var root = document.Root ?? throw new InvalidDataException("Normalized SCL has no root element."); + var ied = root.Elements(Scl + "IED").Single(); + if (!authoritativeIedName.Equals((string?)ied.Attribute("name"), StringComparison.Ordinal)) + throw new InvalidDataException("Normalized SCL IED identity does not match the authoritative IED name."); + + if (root.Descendants(Scl + "ConnectedAP").Any(ap => + !authoritativeIedName.Equals((string?)ap.Attribute("iedName"), StringComparison.Ordinal))) + { + throw new InvalidDataException("ConnectedAP identity is inconsistent with the normalized IED name."); + } + + var exportedCommunicationNames = root.Descendants(Scl + "LDevice") + .Select(ld => + { + var explicitName = ((string?)ld.Attribute("ldName") ?? string.Empty).Trim(); + var inst = ((string?)ld.Attribute("inst") ?? string.Empty).Trim(); + return string.IsNullOrWhiteSpace(explicitName) + ? $"{authoritativeIedName}{inst}" + : explicitName; + }) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var missingDomains = model.LogicalDevices + .Select(LogicalDeviceDomain) + .Where(domain => !exportedCommunicationNames.Contains(domain)) + .ToArray(); + if (missingDomains.Length > 0) + { + throw new InvalidDataException( + $"Normalized SCL lost MMS Logical Device domain(s): {string.Join(", ", missingDomains)}."); + } + } + + private static string LogicalDeviceDomain(LiveIedLogicalDeviceModel logicalDevice) + => string.IsNullOrWhiteSpace(logicalDevice.MmsDomain) + ? logicalDevice.Inst.Trim() + : logicalDevice.MmsDomain.Trim(); + + private static string SafeXmlName(string value) + { + var chars = value.Trim() + .Select(character => char.IsLetterOrDigit(character) || character is '_' or '-' ? character : '_') + .ToArray(); + var result = new string(chars); + if (string.IsNullOrWhiteSpace(result)) + return "LIVE_IED"; + return char.IsLetter(result[0]) || result[0] == '_' ? result : $"_{result}"; + } +} From a0af9ed0ebbc412838f88b007e41a72414186693 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:22:51 +0700 Subject: [PATCH 7/9] Exercise authoritative identity normalization --- .../Scl/LiveIedSclIedIdentityTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs index cff16d9..a89c62f 100644 --- a/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs @@ -55,15 +55,18 @@ public void Exporter_Uses_Authoritative_Ied_Name_And_Preserves_Explicit_Mms_Doma ] }; - var document = LiveIedSclExporter.BuildDocument( + var generated = LiveIedSclExporter.BuildDocument( model, new LiveIedSclExportOptions { Profile = "full-model", SchemaProfile = SclSchemaProfile.Edition1V16, - IedNameOverride = "OCR7SJ8Mod2", IpAddress = "1.110.1.1" }); + var document = AuthoritativeLiveIedSclExporter.ApplyIdentity( + generated, + model, + "OCR7SJ8Mod2"); var root = Assert.IsType(document.Root); var ns = root.Name.Namespace; @@ -96,7 +99,8 @@ public void Exporter_Keeps_Implicit_Product_Naming_When_Domain_Starts_With_Ied_N LogicalDevices = [LogicalDevice("IED1PROT")] }; - var document = LiveIedSclExporter.BuildDocument(model, new LiveIedSclExportOptions()); + var generated = LiveIedSclExporter.BuildDocument(model, new LiveIedSclExportOptions()); + var document = AuthoritativeLiveIedSclExporter.ApplyIdentity(generated, model, "IED1"); var root = Assert.IsType(document.Root); var ns = root.Name.Namespace; var logicalDevice = Assert.Single(root.Descendants(ns + "LDevice")); From d389bbed704460ebe53c3dd3c13564bbc73a52d3 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:23:44 +0700 Subject: [PATCH 8/9] Remove temporary identity patch workflow --- .../apply-live-scl-ied-identity-temp.yml | 117 ------------------ 1 file changed, 117 deletions(-) delete mode 100644 .github/workflows/apply-live-scl-ied-identity-temp.yml diff --git a/.github/workflows/apply-live-scl-ied-identity-temp.yml b/.github/workflows/apply-live-scl-ied-identity-temp.yml deleted file mode 100644 index 30aa8be..0000000 --- a/.github/workflows/apply-live-scl-ied-identity-temp.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Temporary apply live SCL IED identity fix - -on: - push: - branches: [ fix/live-scl-ied-identity ] - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/live-scl-ied-identity - fetch-depth: 0 - - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - - name: Apply exporter identity patch - shell: python - run: | - from pathlib import Path - - path = Path('src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs') - text = path.read_text(encoding='utf-8') - - old = 'EffectiveIedName(model)' - if text.count(old) != 4: - raise SystemExit(f'Expected 4 EffectiveIedName(model) calls, found {text.count(old)}') - text = text.replace(old, 'EffectiveIedName(model, options)', 3) - - old_map = 'StripIedNamePrefix(domain, model.IedName)' - new_map = 'StripIedNamePrefix(domain, EffectiveIedName(model, options))' - if old_map not in text: - raise SystemExit('Logical-device identity mapping anchor not found') - text = text.replace(old_map, new_map, 1) - - old_ld = ' var lDevice = new XElement(Scl + "LDevice", new XAttribute("inst", sclLdInst));' - new_ld = ''' var effectiveIedName = EffectiveIedName(model, options); - var implicitCommunicationName = $"{effectiveIedName}{sclLdInst}"; - var explicitCommunicationName = !ldDomain.Equals(implicitCommunicationName, StringComparison.OrdinalIgnoreCase) - ? new XAttribute("ldName", ldDomain) - : null; - var lDevice = new XElement( - Scl + "LDevice", - new XAttribute("inst", sclLdInst), - explicitCommunicationName);''' - if old_ld not in text: - raise SystemExit('LDevice construction anchor not found') - text = text.replace(old_ld, new_ld, 1) - - old_reconstructed = ''' private static string ReconstructedLogicalDeviceReference( - LiveIedModelDiscoveryDocument model, - LiveIedSclBuildContext context, - string domain) - { - var sclLdInst = SclLogicalDeviceInstByDomain(context, domain); - return $"{EffectiveIedName(model)}{sclLdInst}"; - }''' - new_reconstructed = ''' private static string ReconstructedLogicalDeviceReference( - LiveIedModelDiscoveryDocument model, - LiveIedSclBuildContext context, - string domain) - { - _ = model; - _ = context; - // The MMS domain is the communication-level Logical Device name. It can be - // explicitly configured through LDevice.ldName and must not be reconstructed - // by concatenating an inferred IED name with an arbitrary LD instance. - return domain.Trim(); - }''' - if old_reconstructed not in text: - raise SystemExit('Reconstructed Logical Device reference anchor not found') - text = text.replace(old_reconstructed, new_reconstructed, 1) - - old_effective = ''' private static string EffectiveIedName(LiveIedModelDiscoveryDocument model) - => string.IsNullOrWhiteSpace(model.IedName) ? "LIVE_IED" : SafeXmlName(model.IedName);''' - new_effective = ''' private static string EffectiveIedName( - LiveIedModelDiscoveryDocument model, - LiveIedSclExportOptions options) - { - var authoritative = string.IsNullOrWhiteSpace(options.IedNameOverride) - ? model.IedName - : options.IedNameOverride; - return string.IsNullOrWhiteSpace(authoritative) ? "LIVE_IED" : SafeXmlName(authoritative); - }''' - if old_effective not in text: - raise SystemExit('Effective IED name method anchor not found') - text = text.replace(old_effective, new_effective, 1) - - path.write_text(text, encoding='utf-8') - - - name: Verify focused identity contracts - shell: bash - run: | - grep -q 'IedNameOverride' src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs - grep -q 'new XAttribute("ldName", ldDomain)' src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs - grep -q 'return domain.Trim();' src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs - grep -q 'OCR7SJ8Mod2' tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs - - - name: Test engine - run: dotnet test ARIEC61850.sln -c Release --no-restore - - - name: Commit source and remove temporary workflow - shell: bash - run: | - git rm .github/workflows/apply-live-scl-ied-identity-temp.yml .github/live-scl-ied-identity-trigger.txt - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs tests/AR.Iec61850.Tests/Scl/LiveIedSclIedIdentityTests.cs - git commit -m "Preserve authoritative IED identity in live SCL export" - git push origin HEAD:fix/live-scl-ied-identity From 7dcca4d8ed0b75b547206c00f5589a1ba65ae7b7 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 14:23:58 +0700 Subject: [PATCH 9/9] Remove temporary identity patch trigger --- .github/live-scl-ied-identity-trigger.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/live-scl-ied-identity-trigger.txt diff --git a/.github/live-scl-ied-identity-trigger.txt b/.github/live-scl-ied-identity-trigger.txt deleted file mode 100644 index 6bfbd74..0000000 --- a/.github/live-scl-ied-identity-trigger.txt +++ /dev/null @@ -1 +0,0 @@ -apply-2