Skip to content
Merged
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
91 changes: 66 additions & 25 deletions src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 41 additions & 9 deletions src/AR.Iec61850/FaultRecords/Iec61850FaultRecordService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum Iec61850FaultRecordFileKind
Header,
Information,
Combined,
Archive
Archive,
VendorPackage
}

public sealed class Iec61850FaultRecordFile
Expand All @@ -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
Expand Down Expand Up @@ -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<MmsFileDirectoryEntry> entries,
Expand All @@ -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();
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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}";
}
Expand Down Expand Up @@ -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;
}
Expand Down
20 changes: 19 additions & 1 deletion src/AR.Iec61850/Mms/MmsFileDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,25 @@ public sealed class MmsFileDirectoryEntry
public uint? SizeBytes { get; init; }
public byte[] LastModifiedRaw { get; init; } = Array.Empty<byte>();
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
Expand Down
20 changes: 20 additions & 0 deletions tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
72 changes: 72 additions & 0 deletions tests/AR.Iec61850.Tests/FaultRecordVendorPackageTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading