From d237e132128ddabf6c5251a962bd519a3f3207a4 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 15:38:22 +0700 Subject: [PATCH 1/5] Fix IED identity inference across mixed logical-device domains --- .../Discovery/LiveIedIdentityResolver.cs | 91 ++++++++++++++----- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index ece7926..8508a17 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -42,53 +42,94 @@ public static LiveIedIdentity Resolve( [$"IED name was supplied explicitly as '{name}'."]); } - var suffixCandidates = materialized - .Select(domain => TryExtractKnownLogicalDevicePrefix(domain, out var candidate) ? candidate : string.Empty) - .Where(candidate => !string.IsNullOrWhiteSpace(candidate)) + var suffixMatches = materialized + .Select(domain => new + { + Domain = domain, + Candidate = TryExtractKnownLogicalDevicePrefix(domain, out var candidate) ? candidate : string.Empty + }) + .Where(match => !string.IsNullOrWhiteSpace(match.Candidate)) .ToArray(); - var distinctSuffixCandidates = suffixCandidates + var distinctSuffixCandidates = suffixMatches + .Select(match => match.Candidate) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(candidate => candidate, StringComparer.OrdinalIgnoreCase) .ToArray(); - if (distinctSuffixCandidates.Length == 1) + // A known LD suffix is authoritative only when it explains the complete domain set. + // A single nested domain such as OCR7SJ8Mod2_MU1 must never rename the physical IED + // to OCR7SJ8Mod2 while sibling domains prove the shared OCR7SJ8 prefix. + if (materialized.Length > 0 && suffixMatches.Length == materialized.Length) { - var name = distinctSuffixCandidates[0]; - var confidence = suffixCandidates.Length == materialized.Length && materialized.Length > 1 - ? LiveIedDiscoveryConfidenceLevel.High - : LiveIedDiscoveryConfidenceLevel.Medium; - var evidence = materialized - .Where(domain => domain.StartsWith(name, StringComparison.OrdinalIgnoreCase)) - .Select(domain => $"MMS domain '{domain}' matched the logical-device suffix pattern for IED '{name}'.") - .ToArray(); + if (distinctSuffixCandidates.Length == 1) + { + var name = distinctSuffixCandidates[0]; + var confidence = materialized.Length > 1 + ? LiveIedDiscoveryConfidenceLevel.High + : LiveIedDiscoveryConfidenceLevel.Medium; + var evidence = suffixMatches + .Select(match => $"MMS domain '{match.Domain}' matched the logical-device suffix pattern for IED '{name}'.") + .ToArray(); - return Create(name, "MmsDomainKnownLogicalDeviceSuffix", confidence, false, distinctSuffixCandidates, materialized, evidence); - } + return Create(name, "MmsDomainKnownLogicalDeviceSuffix", confidence, false, distinctSuffixCandidates, materialized, evidence); + } - if (distinctSuffixCandidates.Length > 1) - { - return CreateFallback( - host, - fallbackName, - distinctSuffixCandidates, - materialized, - true, - $"MMS domains produced conflicting IED-name candidates: {string.Join(", ", distinctSuffixCandidates)}."); + if (distinctSuffixCandidates.Length > 1) + { + return CreateFallback( + host, + fallbackName, + distinctSuffixCandidates, + materialized, + true, + $"MMS domains produced conflicting IED-name candidates: {string.Join(", ", distinctSuffixCandidates)}."); + } } var commonPrefix = InferCommonPrefix(materialized); if (!string.IsNullOrWhiteSpace(commonPrefix)) { + var confidence = materialized.Length >= 3 + ? LiveIedDiscoveryConfidenceLevel.High + : LiveIedDiscoveryConfidenceLevel.Medium; return Create( commonPrefix, "MmsDomainCommonPrefix", - LiveIedDiscoveryConfidenceLevel.Medium, + confidence, false, [commonPrefix], materialized, [$"IED name '{commonPrefix}' was derived from the common prefix of {materialized.Length} MMS domain(s)."]); } + // Retain the useful single-domain behavior (for example OLSF501LD0), but do not + // let one partially matching domain dominate a larger mixed domain inventory. + if (distinctSuffixCandidates.Length == 1 && + (materialized.Length == 1 || + (suffixMatches.Length >= 2 && materialized.All(domain => domain.StartsWith(distinctSuffixCandidates[0], StringComparison.OrdinalIgnoreCase))))) + { + var name = distinctSuffixCandidates[0]; + return Create( + name, + "MmsDomainKnownLogicalDeviceSuffix", + materialized.Length == 1 ? LiveIedDiscoveryConfidenceLevel.Medium : LiveIedDiscoveryConfidenceLevel.High, + false, + distinctSuffixCandidates, + materialized, + suffixMatches.Select(match => $"MMS domain '{match.Domain}' matched the logical-device suffix pattern for IED '{name}'.").ToArray()); + } + + if (distinctSuffixCandidates.Length > 1) + { + return CreateFallback( + host, + fallbackName, + distinctSuffixCandidates, + materialized, + true, + $"MMS domains produced conflicting IED-name candidates: {string.Join(", ", distinctSuffixCandidates)}."); + } + return CreateFallback( host, fallbackName, From 4bcfa9be6b7aa347003e581242aef9d8772d5d24 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 15:38:56 +0700 Subject: [PATCH 2/5] Add Siemens mixed-domain IED identity regression --- .../Discovery/LiveIedIdentityResolverTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index d611cf3..308799b 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -30,6 +30,26 @@ public void Resolve_Uses_All_Domains_To_Confirm_The_Ied_Name() Assert.Equal("PROT", identity.LogicalDeviceAliases["OLSF501PROT"]); } + [Fact] + public void Resolve_Does_Not_Promote_A_Nested_Mu_Domain_To_The_Physical_Ied_Name() + { + var identity = LiveIedIdentityResolver.Resolve( + [ + "OCR7SJ8Application", + "OCR7SJ8CB1", + "OCR7SJ8Dc1", + "OCR7SJ8Mod2_MU1", + "OCR7SJ8V3p1_5051OC3phase1" + ], + "1.110.1.1"); + + Assert.Equal("OCR7SJ8", identity.IedName); + Assert.Equal("MmsDomainCommonPrefix", identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal("Mod2_MU1", identity.LogicalDeviceAliases["OCR7SJ8Mod2_MU1"]); + Assert.DoesNotContain("OCR7SJ8Mod2", identity.CandidateNames); + } + [Fact] public void Resolve_Rejects_Conflicting_Domain_Candidates() { From 75a16d4c2f6d606fe3c7c58bed9fc5d9f3be2210 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 15:39:47 +0700 Subject: [PATCH 3/5] Distinguish extensionless files from remote directories --- src/AR.Iec61850/Mms/MmsFileDirectory.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsFileDirectory.cs b/src/AR.Iec61850/Mms/MmsFileDirectory.cs index 398c670..53a66b8 100644 --- a/src/AR.Iec61850/Mms/MmsFileDirectory.cs +++ b/src/AR.Iec61850/Mms/MmsFileDirectory.cs @@ -10,7 +10,25 @@ public sealed class MmsFileDirectoryEntry public uint? SizeBytes { get; init; } public byte[] LastModifiedRaw { get; init; } = Array.Empty(); public string LastModifiedDisplay => LastModifiedRaw.Length == 0 ? string.Empty : Convert.ToHexString(LastModifiedRaw); - public bool IsLikelyDirectory => string.IsNullOrWhiteSpace(System.IO.Path.GetExtension(Name)); + + // Several protection relays expose disturbance packages with no filename extension + // (for example FRA00019). A non-zero declared size proves such an entry is a file, + // not a directory. Directories are accepted when the server uses a trailing separator + // or reports an extensionless zero/unknown-size entry. + public bool IsLikelyDirectory + { + get + { + var hasDirectoryTerminator = + Name.EndsWith('/') || Name.EndsWith('\\') || + Path.EndsWith('/') || Path.EndsWith('\\'); + if (hasDirectoryTerminator) + return true; + + return string.IsNullOrWhiteSpace(System.IO.Path.GetExtension(Name)) && + (!SizeBytes.HasValue || SizeBytes.Value == 0); + } + } } public sealed class MmsFileDirectoryResult From 46568a9426b006d106fa4b0907ed7886b99712ef Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 15:42:25 +0700 Subject: [PATCH 4/5] Support extensionless IED fault-record packages --- .../Iec61850FaultRecordService.cs | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs index 5089ebe..b38a88a 100644 --- a/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs +++ b/src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs @@ -11,7 +11,8 @@ public enum Iec61850FaultRecordFileKind Header, Information, Combined, - Archive + Archive, + VendorPackage } public sealed class Iec61850FaultRecordFile @@ -38,6 +39,7 @@ public sealed class Iec61850FaultRecordSet public long KnownSizeBytes { get; init; } public bool HasUnknownSize { get; init; } public DateTimeOffset? LastModifiedUtc { get; init; } + public bool CanDownload => Files.Count > 0; } public sealed class Iec61850FaultRecordCatalog @@ -110,8 +112,16 @@ public static class Iec61850FaultRecordCatalogBuilder [".zip"] = Iec61850FaultRecordFileKind.Archive }; + private static readonly string[] VendorPackagePrefixes = + [ + "FRA", "FAULT", "DIST", "COMTRADE", "OSC", "RECORD", "REC", "DR" + ]; + public static bool IsSupportedFile(string path) - => SupportedExtensions.ContainsKey(Path.GetExtension(path ?? string.Empty)); + { + var normalized = (path ?? string.Empty).Trim().Replace('\\', '/'); + return SupportedExtensions.ContainsKey(Path.GetExtension(normalized)) || LooksLikeVendorFaultRecordPackage(normalized); + } public static Iec61850FaultRecordCatalog Build( IEnumerable entries, @@ -121,7 +131,7 @@ public static Iec61850FaultRecordCatalog Build( ArgumentNullException.ThrowIfNull(entries); var files = entries - .Where(entry => IsSupportedFile(entry.Path)) + .Where(entry => !entry.IsLikelyDirectory && IsSupportedFile(entry.Path)) .Select(ToFaultRecordFile) .DistinctBy(file => file.RemotePath, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -155,7 +165,9 @@ private static Iec61850FaultRecordFile ToFaultRecordFile(MmsFileDirectoryEntry e var directory = Iec61850RemotePath.GetDirectoryName(remotePath); var extension = Path.GetExtension(name).ToLowerInvariant(); var baseName = Path.GetFileNameWithoutExtension(name); - var kind = SupportedExtensions[extension]; + var kind = SupportedExtensions.TryGetValue(extension, out var knownKind) + ? knownKind + : Iec61850FaultRecordFileKind.VendorPackage; return new Iec61850FaultRecordFile { @@ -183,7 +195,8 @@ private static Iec61850FaultRecordSet BuildRecord( var hasData = files.Any(file => file.Kind == Iec61850FaultRecordFileKind.Data); var hasCombined = files.Any(file => file.Kind == Iec61850FaultRecordFileKind.Combined); var hasArchive = files.Any(file => file.Kind == Iec61850FaultRecordFileKind.Archive); - var complete = hasCombined || hasArchive || (hasConfiguration && hasData); + var hasVendorPackage = files.Any(file => file.Kind == Iec61850FaultRecordFileKind.VendorPackage); + var complete = hasVendorPackage || hasCombined || hasArchive || (hasConfiguration && hasData); var first = files[0]; var modifiedTimes = files .Where(file => file.LastModifiedUtc.HasValue) @@ -197,7 +210,7 @@ private static Iec61850FaultRecordSet BuildRecord( BaseName = first.BaseName, Files = files, IsComplete = complete, - Completeness = DescribeCompleteness(hasConfiguration, hasData, hasCombined, hasArchive), + Completeness = DescribeCompleteness(hasConfiguration, hasData, hasCombined, hasArchive, hasVendorPackage), KnownSizeBytes = files.Sum(file => (long)(file.SizeBytes ?? 0)), HasUnknownSize = files.Any(file => !file.SizeBytes.HasValue || file.SizeBytes.Value == 0), LastModifiedUtc = modifiedTimes.Length == 0 ? null : modifiedTimes.Max() @@ -208,8 +221,11 @@ private static string DescribeCompleteness( bool hasConfiguration, bool hasData, bool hasCombined, - bool hasArchive) + bool hasArchive, + bool hasVendorPackage) { + if (hasVendorPackage) + return "IED fault-record package"; if (hasCombined) return "Combined COMTRADE file"; if (hasArchive) @@ -223,6 +239,22 @@ private static string DescribeCompleteness( return "Incomplete"; } + private static bool LooksLikeVendorFaultRecordPackage(string path) + { + var name = Iec61850RemotePath.GetFileName(path); + if (string.IsNullOrWhiteSpace(name) || !string.IsNullOrWhiteSpace(Path.GetExtension(name))) + return false; + + var compact = new string(name + .Where(char.IsLetterOrDigit) + .Select(char.ToUpperInvariant) + .ToArray()); + if (!compact.Any(char.IsDigit)) + return false; + + return VendorPackagePrefixes.Any(prefix => compact.StartsWith(prefix, StringComparison.Ordinal)); + } + private static string BuildRecordId(string directory, string baseName) => string.IsNullOrWhiteSpace(directory) ? baseName : $"{directory}/{baseName}"; } @@ -537,8 +569,8 @@ public static string NormalizeFile(string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path); var normalized = NormalizeSegments(path.Trim().Replace('\\', '/'), allowEmpty: false); - if (string.IsNullOrWhiteSpace(Path.GetExtension(GetFileName(normalized)))) - throw new ArgumentException("Remote file path has no supported file extension.", nameof(path)); + if (string.IsNullOrWhiteSpace(GetFileName(normalized))) + throw new ArgumentException("Remote file path has no usable filename.", nameof(path)); return normalized; } From c3c30696c2bcce2166c6c3864acb263a73f9ae5b Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 15:42:46 +0700 Subject: [PATCH 5/5] Add extensionless relay fault-record regression tests --- .../FaultRecordVendorPackageTests.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/FaultRecordVendorPackageTests.cs diff --git a/tests/AR.Iec61850.Tests/FaultRecordVendorPackageTests.cs b/tests/AR.Iec61850.Tests/FaultRecordVendorPackageTests.cs new file mode 100644 index 0000000..774e991 --- /dev/null +++ b/tests/AR.Iec61850.Tests/FaultRecordVendorPackageTests.cs @@ -0,0 +1,72 @@ +using System.Text; +using AR.Iec61850.FaultRecords; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests; + +public sealed class FaultRecordVendorPackageTests +{ + [Fact] + public void Extensionless_NonZero_Entry_Is_A_File_Not_A_Directory() + { + var entry = new MmsFileDirectoryEntry + { + Name = "FRA00019", + Path = "FRA00019", + SizeBytes = 119 * 1024 + }; + + Assert.False(entry.IsLikelyDirectory); + } + + [Fact] + public void Extensionless_ZeroSize_Entry_Remains_A_Directory_Candidate() + { + var entry = new MmsFileDirectoryEntry + { + Name = "COMTRADE", + Path = "COMTRADE", + SizeBytes = 0 + }; + + Assert.True(entry.IsLikelyDirectory); + } + + [Fact] + public void Catalog_Exposes_Extensionless_Fra_Record_As_Downloadable_Package() + { + var catalog = Iec61850FaultRecordCatalogBuilder.Build( + [ + new MmsFileDirectoryEntry + { + Name = "FRA00019", + Path = "FRA00019", + SizeBytes = 119 * 1024, + LastModifiedRaw = Encoding.ASCII.GetBytes("20260709135100Z") + }, + new MmsFileDirectoryEntry + { + Name = "TEST FILE.txt", + Path = "TEST FILE.txt", + SizeBytes = 0 + } + ]); + + var record = Assert.Single(catalog.Records); + var file = Assert.Single(record.Files); + Assert.Equal("FRA00019", record.BaseName); + Assert.True(record.IsComplete); + Assert.True(record.CanDownload); + Assert.Equal("IED fault-record package", record.Completeness); + Assert.Equal(Iec61850FaultRecordFileKind.VendorPackage, file.Kind); + Assert.Equal("FRA00019", file.RemotePath); + } + + [Fact] + public void FileOpen_Allows_Extensionless_Remote_File_Name() + { + var request = MmsFileOpenRequest.Build(3, "FRA00019"); + + Assert.NotEmpty(request); + } +}