From 9fdf3204497edaebd2f5501f11c6e612ef98cb7e Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 16:14:30 +0700 Subject: [PATCH 1/8] Fix MMS FileDirectory entry decoding and path fidelity --- src/AR.Iec61850/Mms/MmsFileDirectory.cs | 135 +++++++++++++++++------- 1 file changed, 99 insertions(+), 36 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsFileDirectory.cs b/src/AR.Iec61850/Mms/MmsFileDirectory.cs index 53a66b8..ed02761 100644 --- a/src/AR.Iec61850/Mms/MmsFileDirectory.cs +++ b/src/AR.Iec61850/Mms/MmsFileDirectory.cs @@ -74,16 +74,25 @@ private static bool IsRootFileSpecification(string? value) private static byte[] EncodeFileNameContent(string value) { - var normalized = (value ?? string.Empty).Trim().Replace('\\', '/'); - var parts = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length == 0 && !string.IsNullOrWhiteSpace(normalized)) - parts = new[] { normalized }; + var normalized = NormalizeFileSpecification(value); - var body = Array.Empty(); - foreach (var part in parts) - body = MmsPresentation.Concat(body, BerWriter.EncodeTlv(0x19, BerWriter.EncodeAscii(part))); + // FileName is a SEQUENCE OF GraphicString. Real IEDs commonly expose the complete + // case-sensitive path in one GraphicString with '/' separators, so preserve that + // representation rather than splitting each directory into a separate element. + return BerWriter.EncodeTlv(0x19, BerWriter.EncodeAscii(normalized)); + } - return body; + private static string NormalizeFileSpecification(string value) + { + var segments = (value ?? string.Empty) + .Trim() + .Replace('\\', '/') + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + throw new ArgumentException("MMS file specification has no usable path segment.", nameof(value)); + if (segments.Any(segment => segment is "." or "..")) + throw new ArgumentException("MMS file specification contains a traversal segment.", nameof(value)); + return string.Join('/', segments); } } @@ -137,18 +146,19 @@ public static MmsFileDirectoryResult Decode( var entries = new List(); var moreFollows = false; DecodeServiceResponse(service, dir, entries, ref moreFollows); + var distinctEntries = entries + .DistinctBy(x => x.Path, StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase) + .ToArray(); return new MmsFileDirectoryResult { IsSuccess = true, DirectoryName = dir, ContinueAfter = continuation, - Entries = entries - .DistinctBy(x => x.Path, StringComparer.OrdinalIgnoreCase) - .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase) - .ToArray(), + Entries = distinctEntries, MoreFollows = moreFollows, - Message = $"MMS FileDirectory decoded {entries.Count} entr(y/ies), moreFollows={moreFollows}.", + Message = $"MMS FileDirectory decoded {distinctEntries.Length} entr(y/ies), moreFollows={moreFollows}.", ResponseHexPreview = hex }; } @@ -158,26 +168,65 @@ public static MmsFileDirectoryResult Decode( } } - private static void DecodeServiceResponse(BerTlv service, string directoryName, List entries, ref bool moreFollows) + private static void DecodeServiceResponse( + BerTlv service, + string directoryName, + List entries, + ref bool moreFollows) { foreach (var child in BerReader.ReadChildren(service.Value)) { if (child.Class == BerClass.ContextSpecific && child.TagNumber == 0 && child.Constructed) { - foreach (var entry in BerReader.ReadChildren(child.Value)) - { - var decoded = DecodeDirectoryEntry(entry, directoryName); - if (decoded != null) - entries.Add(decoded); - } + CollectDirectoryEntries(child, directoryName, entries, depth: 0); } else if (child.Class == BerClass.ContextSpecific && child.TagNumber == 1 && child.Value.Length > 0) { - moreFollows = child.Value.Span[0] != 0; + moreFollows = BerReader.ReadBoolean(child) ?? child.Value.Span[0] != 0; } } } + private static void CollectDirectoryEntries( + BerTlv container, + string directoryName, + List entries, + int depth) + { + if (!container.Constructed || depth > 6) + return; + + foreach (var child in BerReader.ReadChildren(container.Value)) + { + if (LooksLikeDirectoryEntry(child)) + { + var decoded = DecodeDirectoryEntry(child, directoryName); + if (decoded != null) + entries.Add(decoded); + } + else if (child.Constructed) + { + // Some stacks encode listOfDirectoryEntry as [0] -> SEQUENCE -> + // DirectoryEntry SEQUENCE(s), while others place the DirectoryEntry + // SEQUENCE(s) directly under [0]. Accept both without collapsing all + // filenames into one synthetic entry. + CollectDirectoryEntries(child, directoryName, entries, depth + 1); + } + } + } + + private static bool LooksLikeDirectoryEntry(BerTlv candidate) + { + if (!candidate.Constructed) + return false; + + var fields = BerReader.ReadChildren(candidate.Value); + return fields.Any(field => + field.Class == BerClass.ContextSpecific && + field.TagNumber == 0 && + field.Constructed); + } + private static MmsFileDirectoryEntry? DecodeDirectoryEntry(BerTlv entry, string directoryName) { if (!entry.Constructed) @@ -189,14 +238,11 @@ private static void DecodeServiceResponse(BerTlv service, string directoryName, foreach (var field in BerReader.ReadChildren(entry.Value)) { - if (field.EncodedTag == 0x30 || field.Constructed) + if (field.Class == BerClass.ContextSpecific && field.TagNumber == 0 && field.Constructed) { - var candidate = DecodeFileName(field); - if (!string.IsNullOrWhiteSpace(candidate)) - fileName ??= candidate; + fileName = DecodeFileName(field); } - - if (field.Class == BerClass.ContextSpecific && field.TagNumber == 1 && field.Constructed) + else if (field.Class == BerClass.ContextSpecific && field.TagNumber == 1 && field.Constructed) { foreach (var attr in BerReader.ReadChildren(field.Value)) { @@ -211,10 +257,11 @@ private static void DecodeServiceResponse(BerTlv service, string directoryName, if (string.IsNullOrWhiteSpace(fileName)) return null; - var path = CombinePath(directoryName, fileName); + var normalizedName = NormalizeReturnedPath(fileName); + var path = CombinePath(directoryName, normalizedName); return new MmsFileDirectoryEntry { - Name = fileName, + Name = normalizedName, Path = path, SizeBytes = size, LastModifiedRaw = modified @@ -228,7 +275,9 @@ private static string DecodeFileName(BerTlv tlv) var parts = new List(); CollectGraphicStrings(tlv, parts, depth: 0); - return string.Join('/', parts.Where(x => !string.IsNullOrWhiteSpace(x))); + return string.Join('/', parts + .Select(part => part.Trim().Replace('\\', '/')) + .Where(part => !string.IsNullOrWhiteSpace(part))); } private static void CollectGraphicStrings(BerTlv tlv, List parts, int depth) @@ -247,14 +296,28 @@ private static void CollectGraphicStrings(BerTlv tlv, List parts, int de private static string CombinePath(string directoryName, string fileName) { - if (string.IsNullOrWhiteSpace(directoryName)) - return fileName; + var dir = NormalizeReturnedPath(directoryName); + var name = NormalizeReturnedPath(fileName); + if (string.IsNullOrWhiteSpace(dir)) + return name; + if (string.IsNullOrWhiteSpace(name)) + return dir; + + if (name.Equals(dir, StringComparison.OrdinalIgnoreCase) || + name.StartsWith(dir + "/", StringComparison.OrdinalIgnoreCase)) + { + return name; + } - var dir = directoryName.Trim().TrimEnd('/', '\\'); - var name = fileName.Trim().TrimStart('/', '\\'); - return string.IsNullOrWhiteSpace(dir) ? name : $"{dir}/{name}"; + return $"{dir}/{name}"; } + private static string NormalizeReturnedPath(string? value) + => string.Join('/', (value ?? string.Empty) + .Trim() + .Replace('\\', '/') + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + private static MmsFileDirectoryResult Fail(string directoryName, string continueAfter, string message, string hex) => new() { @@ -266,4 +329,4 @@ private static MmsFileDirectoryResult Fail(string directoryName, string continue Message = message, ResponseHexPreview = hex }; -} +} \ No newline at end of file From 0432ccb3b05cbf1e64b1cef55a5b46363fd01f8a Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 16:17:38 +0700 Subject: [PATCH 2/8] Cover real MMS FileDirectory list wrappers and paths --- .../Mms/MmsFileDirectoryTests.cs | 118 +++++++++++++----- 1 file changed, 89 insertions(+), 29 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsFileDirectoryTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsFileDirectoryTests.cs index ebf8d6e..1641fb7 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsFileDirectoryTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsFileDirectoryTests.cs @@ -6,43 +6,103 @@ namespace AR.Iec61850.Tests.Mms; public sealed class MmsFileDirectoryTests { [Fact] - public void Request_Uses_High_Tag_Number_For_FileDirectory_Service() + public void Request_Uses_High_Tag_Number_And_Preserves_Full_File_Specification() { - var request = MmsFileDirectoryRequest.Build(7, "COMTRADE"); + var request = MmsFileDirectoryRequest.Build(7, @"COMTRADE\FRA00019.cfg"); + var mms = MmsPresentation.StripPresentationPrefix(request); + var outer = ReadSingle(mms); + var service = Assert.Single(BerReader.ReadChildren(outer.Value).Skip(1)); + Assert.Equal(BerClass.ContextSpecific, service.Class); + Assert.True(service.Constructed); + Assert.Equal(77, service.TagNumber); - Assert.Contains((byte)0xBF, request); - Assert.Contains((byte)0x4D, request); - Assert.Contains((byte)'C', request); + var specification = Assert.Single(BerReader.ReadChildren(service.Value)); + var graphicString = Assert.Single(BerReader.ReadChildren(specification.Value)); + Assert.Equal((byte)0x19, graphicString.EncodedTag); + Assert.Equal("COMTRADE/FRA00019.cfg", BerReader.ReadAsciiString(graphicString)); } [Fact] - public void Decode_Reads_FileDirectory_Entries() + public void Decode_Reads_Standard_Nested_List_Without_Collapsing_Companion_Files() { - var fileName = BerWriter.EncodeTlv(0x30, - BerWriter.EncodeTlv(0x19, BerWriter.EncodeAscii("COMTRADE")) - .Concat(BerWriter.EncodeTlv(0x19, BerWriter.EncodeAscii("fault.cfg"))) - .ToArray()); - var attributes = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: true, 1, - BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: false, 0, BerWriter.EncodeUnsignedInteger(1234)) - .Concat(BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: false, 1, new byte[] { 0x01, 0x02, 0x03, 0x04 })) - .ToArray()); - var entry = BerWriter.EncodeTlv(0x30, fileName.Concat(attributes).ToArray()); - var list = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: true, 0, entry); - var more = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: false, 1, new byte[] { 0x00 }); - var service = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: true, 77, list.Concat(more).ToArray()); - var response = BerWriter.EncodeTlv(0xA1, - new byte[] { 0x02, 0x01, 0x07 } - .Concat(service) - .ToArray()); + var cfg = BuildDirectoryEntry("COMTRADE/FRA00019.cfg", 2048, [0x01, 0x02]); + var dat = BuildDirectoryEntry("COMTRADE/FRA00019.dat", 119_000, [0x03, 0x04]); + var sequenceOfEntries = BerWriter.EncodeTlv(0x30, cfg.Concat(dat).ToArray()); + var list = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: true, 0, sequenceOfEntries); + var more = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: false, 1, [0x00]); + var response = BuildConfirmedResponse(7, list.Concat(more).ToArray()); - var result = MmsFileDirectoryResponseDecoder.Decode(response, 7, string.Empty); + var result = MmsFileDirectoryResponseDecoder.Decode(response, 7, "COMTRADE"); Assert.True(result.IsSuccess, result.Message); Assert.False(result.MoreFollows); - var entryResult = Assert.Single(result.Entries); - Assert.Equal("COMTRADE/fault.cfg", entryResult.Name); - Assert.Equal("COMTRADE/fault.cfg", entryResult.Path); - Assert.Equal((uint)1234, entryResult.SizeBytes.GetValueOrDefault()); - Assert.Equal("01020304", entryResult.LastModifiedDisplay); + Assert.Equal(2, result.Entries.Count); + Assert.Collection( + result.Entries, + entry => + { + Assert.Equal("COMTRADE/FRA00019.cfg", entry.Name); + Assert.Equal("COMTRADE/FRA00019.cfg", entry.Path); + Assert.Equal((uint)2048, entry.SizeBytes); + }, + entry => + { + Assert.Equal("COMTRADE/FRA00019.dat", entry.Name); + Assert.Equal("COMTRADE/FRA00019.dat", entry.Path); + Assert.Equal((uint)119_000, entry.SizeBytes); + }); + } + + [Fact] + public void Decode_Accepts_Directory_Entries_Directly_Under_List_Wrapper() + { + var entry = BuildDirectoryEntry("fault.cfg", 1234, [0x01, 0x02, 0x03, 0x04]); + var list = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: true, 0, entry); + var more = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: false, 1, [0x00]); + var response = BuildConfirmedResponse(8, list.Concat(more).ToArray()); + + var result = MmsFileDirectoryResponseDecoder.Decode(response, 8, "COMTRADE"); + + Assert.True(result.IsSuccess, result.Message); + var decoded = Assert.Single(result.Entries); + Assert.Equal("fault.cfg", decoded.Name); + Assert.Equal("COMTRADE/fault.cfg", decoded.Path); + Assert.Equal((uint)1234, decoded.SizeBytes); + Assert.Equal("01020304", decoded.LastModifiedDisplay); + } + + private static byte[] BuildDirectoryEntry(string fileName, uint size, byte[] modified) + { + var name = BerWriter.EncodeTlv( + BerClass.ContextSpecific, + constructed: true, + 0, + BerWriter.EncodeTlv(0x19, BerWriter.EncodeAscii(fileName))); + var attributes = BerWriter.EncodeTlv( + BerClass.ContextSpecific, + constructed: true, + 1, + BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: false, 0, BerWriter.EncodeUnsignedInteger(size)) + .Concat(BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: false, 1, modified)) + .ToArray()); + return BerWriter.EncodeTlv(0x30, name.Concat(attributes).ToArray()); + } + + private static byte[] BuildConfirmedResponse(int invokeId, byte[] serviceValue) + { + var service = BerWriter.EncodeTlv(BerClass.ContextSpecific, constructed: true, 77, serviceValue); + return BerWriter.EncodeTlv( + 0xA1, + BerWriter.EncodeTlv(0x02, BerWriter.EncodeUnsignedInteger((uint)invokeId)) + .Concat(service) + .ToArray()); + } + + private static BerTlv ReadSingle(ReadOnlyMemory source) + { + var offset = 0; + Assert.True(BerReader.TryReadTlv(source, ref offset, out var tlv)); + Assert.Equal(source.Length, offset); + return tlv; } -} +} \ No newline at end of file From 0fe9c7e5492082642a5d9faf17338f40418edd20 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 16:18:23 +0700 Subject: [PATCH 3/8] Decode live RCB bit strings for exact SCL export --- .../Mms/MmsReportControlFieldCodec.cs | 130 +++++++++++++++++- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs b/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs index 088169b..c936313 100644 --- a/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs +++ b/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs @@ -1,7 +1,8 @@ namespace AR.Iec61850.Mms; /// -/// Encodes the IEC 61850 RCB bit-string fields from engineer-readable names. +/// Encodes and decodes IEC 61850 RCB bit-string fields from engineer-readable names +/// and from the canonical renderer form, for example bits(08, unused=2). /// Bit indexes follow IEC 61850-7-2 ordering (MSB first in the MMS bit-string). /// public static class MmsReportControlFieldCodec @@ -20,6 +21,7 @@ public static class MmsReportControlFieldCodec ["dataupdate"] = 2, ["integrity"] = 3, ["intg"] = 3, + ["period"] = 3, ["gi"] = 4, ["general-interrogation"] = 4, ["generalinterrogation"] = 4, @@ -32,24 +34,30 @@ public static class MmsReportControlFieldCodec { ["sequence-number"] = 1, ["sequencenumber"] = 1, - ["sqnum"] = 1, + ["seqnum"] = 1, ["report-timestamp"] = 2, ["reporttimestamp"] = 2, ["time-of-entry"] = 2, ["timeofentry"] = 2, + ["timestamp"] = 2, ["reason-for-inclusion"] = 3, ["reasonforinclusion"] = 3, + ["reason"] = 3, ["data-set"] = 4, + ["data-set-name"] = 4, ["dataset"] = 4, ["data-reference"] = 5, ["datareference"] = 5, + ["dataref"] = 5, ["buffer-overflow"] = 6, ["bufferoverflow"] = 6, + ["bufovfl"] = 6, ["entryid"] = 7, ["entry-id"] = 7, ["conf-revision"] = 8, ["confrevision"] = 8, ["confrev"] = 8, + ["configref"] = 8, ["segmentation"] = 9 }; @@ -59,6 +67,37 @@ public static bool TryEncodeTriggerOptions(string? text, out MmsDataValue value) public static bool TryEncodeOptionalFields(string? text, out MmsDataValue value) => TryEncode(text, OptionalFieldBits, bitCount: 10, out value); + public static MmsReportTriggerOptionFlags DecodeTriggerOptions(string? text) + { + var bits = DecodeBits(text, TriggerOptionBits, bitCount: 6); + return new MmsReportTriggerOptionFlags + { + DataChange = bits[0], + QualityChange = bits[1], + DataUpdate = bits[2], + Integrity = bits[3], + GeneralInterrogation = bits[4], + ApplicationTrigger = bits[5] + }; + } + + public static MmsReportOptionalFieldFlags DecodeOptionalFields(string? text) + { + var bits = DecodeBits(text, OptionalFieldBits, bitCount: 10); + return new MmsReportOptionalFieldFlags + { + SequenceNumber = bits[1], + ReportTimestamp = bits[2], + ReasonForInclusion = bits[3], + DataSetName = bits[4], + DataReference = bits[5], + BufferOverflow = bits[6], + EntryId = bits[7], + ConfigurationRevision = bits[8], + Segmentation = bits[9] + }; + } + private static bool TryEncode( string? text, IReadOnlyDictionary map, @@ -66,10 +105,10 @@ private static bool TryEncode( out MmsDataValue value) { value = MmsDataValue.BitString((byte)((8 - bitCount % 8) % 8), ReadOnlySpan.Empty); - var bits = Tokenize(text) - .Select(token => map.TryGetValue(token, out var bit) ? bit : -1) - .Where(bit => bit >= 0 && bit < bitCount) - .Distinct() + var bits = DecodeBits(text, map, bitCount) + .Select((enabled, index) => (enabled, index)) + .Where(item => item.enabled) + .Select(item => item.index) .ToArray(); if (bits.Length == 0) return false; @@ -83,8 +122,87 @@ private static bool TryEncode( return true; } + private static bool[] DecodeBits( + string? text, + IReadOnlyDictionary map, + int bitCount) + { + var enabled = new bool[bitCount]; + foreach (var token in Tokenize(text)) + { + if (map.TryGetValue(token, out var bit) && bit >= 0 && bit < bitCount) + enabled[bit] = true; + } + + if (TryParseRenderedBitString(text, out var bytes)) + { + for (var bit = 0; bit < bitCount; bit++) + { + var byteIndex = bit / 8; + if (byteIndex < bytes.Length && (bytes[byteIndex] & (0x80 >> (bit % 8))) != 0) + enabled[bit] = true; + } + } + + return enabled; + } + + private static bool TryParseRenderedBitString(string? text, out byte[] bytes) + { + bytes = Array.Empty(); + var source = text ?? string.Empty; + var marker = source.IndexOf("bits(", StringComparison.OrdinalIgnoreCase); + if (marker < 0) + return false; + + var valueStart = marker + 5; + var valueEnd = source.IndexOfAny([',', ')'], valueStart); + if (valueEnd <= valueStart) + return false; + + var hex = source[valueStart..valueEnd] + .Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .Trim(); + if (hex.Length == 0 || (hex.Length & 1) != 0 || !hex.All(Uri.IsHexDigit)) + return false; + + try + { + bytes = Convert.FromHexString(hex); + return bytes.Length > 0; + } + catch (FormatException) + { + return false; + } + } + private static IEnumerable Tokenize(string? text) => (text ?? string.Empty) .Split(new[] { ' ', ',', ';', '|', '+', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(token => token.Trim().Trim('[', ']', '(', ')').ToLowerInvariant()); } + +public sealed class MmsReportTriggerOptionFlags +{ + public bool DataChange { get; init; } + public bool QualityChange { get; init; } + public bool DataUpdate { get; init; } + public bool Integrity { get; init; } + public bool GeneralInterrogation { get; init; } + public bool ApplicationTrigger { get; init; } +} + +public sealed class MmsReportOptionalFieldFlags +{ + public bool SequenceNumber { get; init; } + public bool ReportTimestamp { get; init; } + public bool ReasonForInclusion { get; init; } + public bool DataSetName { get; init; } + public bool DataReference { get; init; } + public bool BufferOverflow { get; init; } + public bool EntryId { get; init; } + public bool ConfigurationRevision { get; init; } + public bool Segmentation { get; init; } +} \ No newline at end of file From bfd68b5d6102ff864c10f55866aec742577f6d03 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 16:19:33 +0700 Subject: [PATCH 4/8] Preserve read-only RCB configuration evidence --- src/AR.Iec61850/Mms/MmsRcbAvailability.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs index 6fcddea..4ce5a73 100644 --- a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs +++ b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs @@ -38,6 +38,10 @@ public sealed class MmsRcbAvailabilitySnapshot public string DataSetReference { get; init; } = string.Empty; public string ReportId { get; init; } = string.Empty; public string ConfRev { get; init; } = string.Empty; + public string BufferTimeMs { get; init; } = string.Empty; + public string IntegrityPeriodMs { get; init; } = string.Empty; + public string TriggerOptions { get; init; } = string.Empty; + public string OptionalFields { get; init; } = string.Empty; public string EnabledState { get; init; } = string.Empty; public string ReservationState { get; init; } = string.Empty; public string ReservationTimeSeconds { get; init; } = string.Empty; @@ -157,6 +161,10 @@ public static MmsRcbAvailabilitySnapshot Evaluate( DataSetReference = candidate.DataSetReference, ReportId = candidate.ReportId, ConfRev = candidate.ConfRev, + BufferTimeMs = candidate.BufferTimeMs, + IntegrityPeriodMs = candidate.IntegrityPeriodMs, + TriggerOptions = candidate.TriggerOptions, + OptionalFields = candidate.OptionalFields, EnabledState = candidate.EnabledState, ReservationState = candidate.ReservationState, ReservationTimeSeconds = candidate.ReservationTimeSeconds, @@ -243,4 +251,4 @@ internal static bool HasOwner(string? value) internal static string NormalizeReference(string? reference) => (reference ?? string.Empty).Trim().Replace('$', '.'); -} +} \ No newline at end of file From df236e53dbcc8ddbcb21ebacd5a8c0de7ca95bc7 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 16:20:11 +0700 Subject: [PATCH 5/8] Merge exact live RCB attributes into selected export model --- .../Export/LiveRcbDataSetEvidenceMerger.cs | 95 ++++++++++++++++--- 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs b/src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs index 1820357..e8547d5 100644 --- a/src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs +++ b/src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs @@ -4,10 +4,8 @@ namespace AR.Iec61850.Scl.Export; /// -/// Reconciles a live discovery model with the exact DataSet directory read during -/// the passive RCB availability check. Some IEDs expose a DataSet count during the -/// initial scan but only return the FCDA member references through an explicit -/// GetDataSetDirectory request. The source model is never mutated. +/// Reconciles a live discovery model with exact read-only evidence collected during +/// the RCB availability check. The source model is never mutated. /// public static class LiveRcbDataSetEvidenceMerger { @@ -26,11 +24,7 @@ public static LiveIedModelDiscoveryDocument MergeSelectedDataSetDirectory( if (selected is null) throw new InvalidOperationException($"ReportControl '{selectedReportControlReference}' was not found in the live model."); - var evidence = availability.ReportControls.FirstOrDefault(snapshot => - Normalize(snapshot.Reference).Equals(selectedReference, StringComparison.OrdinalIgnoreCase)); - if (evidence is null) - throw new InvalidOperationException( - $"No live DataSet directory evidence is available for '{selectedReportControlReference}'. Run Check Availability before exporting this RCB."); + var evidence = FindEvidence(availability, selectedReportControlReference); if (!evidence.DataSetDirectorySuccess) throw new InvalidOperationException( $"The DataSet directory for '{selectedReportControlReference}' was not read successfully. Run Check Availability again before export."); @@ -95,7 +89,79 @@ public static LiveIedModelDiscoveryDocument MergeSelectedDataSetDirectory( : dataSet) .ToArray(); - return new LiveIedModelDiscoveryDocument + return CloneDocument( + source, + dataSets: mergedDataSets, + reportControls: source.ReportControls, + summarySuffix: "Live DataSet directory evidence merged for selected-RCB export."); + } + + public static LiveIedModelDiscoveryDocument MergeSelectedReportControlEvidence( + LiveIedModelDiscoveryDocument source, + string selectedReportControlReference, + MmsRcbAvailabilityResult availability) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(selectedReportControlReference); + ArgumentNullException.ThrowIfNull(availability); + + var selectedReference = Normalize(selectedReportControlReference); + var sourceControl = source.ReportControls.FirstOrDefault(control => + Normalize(control.Reference).Equals(selectedReference, StringComparison.OrdinalIgnoreCase)); + if (sourceControl is null) + throw new InvalidOperationException($"ReportControl '{selectedReportControlReference}' was not found in the live model."); + + var evidence = FindEvidence(availability, selectedReportControlReference); + var mergedControl = new LiveIedReportControlModel + { + Reference = sourceControl.Reference, + Domain = sourceControl.Domain, + LogicalNode = sourceControl.LogicalNode, + Name = sourceControl.Name, + Buffered = sourceControl.Buffered, + DataSetReference = FirstNonEmpty(evidence.DataSetReference, sourceControl.DataSetReference), + ReportId = FirstNonEmpty(evidence.ReportId, sourceControl.ReportId), + ConfRev = FirstNonEmpty(evidence.ConfRev, sourceControl.ConfRev), + TriggerOptions = FirstNonEmpty(evidence.TriggerOptions, sourceControl.TriggerOptions), + OptionalFields = FirstNonEmpty(evidence.OptionalFields, sourceControl.OptionalFields), + BufferTimeMs = FirstNonEmpty(evidence.BufferTimeMs, sourceControl.BufferTimeMs), + IntegrityPeriodMs = FirstNonEmpty(evidence.IntegrityPeriodMs, sourceControl.IntegrityPeriodMs), + EnabledState = FirstNonEmpty(evidence.EnabledState, sourceControl.EnabledState), + ReservationState = FirstNonEmpty(evidence.ReservationState, sourceControl.ReservationState), + ReservationTimeSeconds = FirstNonEmpty(evidence.ReservationTimeSeconds, sourceControl.ReservationTimeSeconds), + Status = sourceControl.Status + }; + + var reportControls = source.ReportControls + .Select(control => Normalize(control.Reference).Equals(selectedReference, StringComparison.OrdinalIgnoreCase) + ? mergedControl + : control) + .ToArray(); + + return CloneDocument( + source, + source.DataSets, + reportControls, + "Exact live RCB configuration evidence merged for selected-RCB export."); + } + + private static MmsRcbAvailabilitySnapshot FindEvidence( + MmsRcbAvailabilityResult availability, + string selectedReportControlReference) + { + var selectedReference = Normalize(selectedReportControlReference); + return availability.ReportControls.FirstOrDefault(snapshot => + Normalize(snapshot.Reference).Equals(selectedReference, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException( + $"No live RCB evidence is available for '{selectedReportControlReference}'. Run Check Availability before exporting this RCB."); + } + + private static LiveIedModelDiscoveryDocument CloneDocument( + LiveIedModelDiscoveryDocument source, + IReadOnlyList dataSets, + IReadOnlyList reportControls, + string summarySuffix) + => new() { SchemaVersion = source.SchemaVersion, GeneratedAtUtc = source.GeneratedAtUtc, @@ -105,12 +171,12 @@ public static LiveIedModelDiscoveryDocument MergeSelectedDataSetDirectory( IedName = source.IedName, IedIdentity = source.IedIdentity, AccessPointName = source.AccessPointName, - Summary = $"{source.Summary} Live DataSet directory evidence merged for selected-RCB export.", + Summary = $"{source.Summary} {summarySuffix}".Trim(), Coverage = source.Coverage, LogicalDevices = source.LogicalDevices, FileDirectory = source.FileDirectory, - DataSets = mergedDataSets, - ReportControls = source.ReportControls, + DataSets = dataSets, + ReportControls = reportControls, GooseControlBlocks = source.GooseControlBlocks, SampledValueControlBlocks = source.SampledValueControlBlocks, SettingGroupControls = source.SettingGroupControls, @@ -119,11 +185,10 @@ public static LiveIedModelDiscoveryDocument MergeSelectedDataSetDirectory( VariableTypeDiscoveries = source.VariableTypeDiscoveries, Warnings = source.Warnings }; - } private static string Normalize(string? reference) => (reference ?? string.Empty).Trim().Replace('$', '.'); private static string FirstNonEmpty(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; -} +} \ No newline at end of file From 7bfda7497d8dc481248848de204b2c32de33beef Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 16:21:03 +0700 Subject: [PATCH 6/8] Export exact live RCB trigger and optional fields --- .../Export/AuthoritativeLiveIedSclExporter.cs | 76 +++++++++++++++++-- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index 499a99a..926e0c5 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -1,11 +1,13 @@ using System.Xml.Linq; using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; 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. +/// communication-level MMS Logical Device domain names and preserving exact read-only +/// ReportControl configuration evidence. /// public static class AuthoritativeLiveIedSclExporter { @@ -20,11 +22,11 @@ public static LiveIedSclExportResult WriteFiles( 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); + if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) + document = ApplyIdentity(document, model, options.IedNameOverride); + + document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); document.Save(result.SclPath); return result; } @@ -81,6 +83,68 @@ public static XDocument ApplyIdentity( return document; } + public static XDocument ApplyReportControlConfiguration( + XDocument source, + LiveIedModelDiscoveryDocument model, + SclSchemaProfileDescriptor schema) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(schema); + + var document = new XDocument(source); + var reportControls = model.ReportControls.ToArray(); + foreach (var element in document.Descendants(Scl + "ReportControl")) + { + var name = ((string?)element.Attribute("name") ?? string.Empty).Trim(); + var buffered = bool.TryParse((string?)element.Attribute("buffered"), out var parsedBuffered) && parsedBuffered; + var matches = reportControls + .Where(control => + control.Name.Equals(name, StringComparison.OrdinalIgnoreCase) && + control.Buffered == buffered) + .ToArray(); + var modelControl = matches.Length == 1 + ? matches[0] + : reportControls.Length == 1 + ? reportControls[0] + : null; + if (modelControl is null) + continue; + + var trigger = MmsReportControlFieldCodec.DecodeTriggerOptions(modelControl.TriggerOptions); + var triggerElement = element.Element(Scl + "TrgOps") ?? new XElement(Scl + "TrgOps"); + triggerElement.SetAttributeValue("dchg", XmlBool(trigger.DataChange)); + triggerElement.SetAttributeValue("qchg", XmlBool(trigger.QualityChange)); + triggerElement.SetAttributeValue("dupd", XmlBool(trigger.DataUpdate)); + triggerElement.SetAttributeValue("period", XmlBool(trigger.Integrity)); + triggerElement.SetAttributeValue( + "gi", + schema.SupportsTriggerGi ? XmlBool(trigger.GeneralInterrogation) : null); + if (triggerElement.Parent is null) + element.Add(triggerElement); + + var optional = MmsReportControlFieldCodec.DecodeOptionalFields(modelControl.OptionalFields); + var optionalElement = element.Element(Scl + "OptFields") ?? new XElement(Scl + "OptFields"); + optionalElement.SetAttributeValue("seqNum", XmlBool(optional.SequenceNumber)); + optionalElement.SetAttributeValue("timeStamp", XmlBool(optional.ReportTimestamp)); + optionalElement.SetAttributeValue("reasonCode", XmlBool(optional.ReasonForInclusion)); + optionalElement.SetAttributeValue("dataSet", XmlBool(optional.DataSetName)); + optionalElement.SetAttributeValue("dataRef", XmlBool(optional.DataReference)); + optionalElement.SetAttributeValue("bufOvfl", XmlBool(optional.BufferOverflow)); + optionalElement.SetAttributeValue("entryID", XmlBool(optional.EntryId)); + optionalElement.SetAttributeValue("configRef", XmlBool(optional.ConfigurationRevision)); + optionalElement.SetAttributeValue( + "segmentation", + schema.IsEdition2 ? XmlBool(optional.Segmentation) : null); + if (optionalElement.Parent is null) + element.Add(optionalElement); + } + + return document; + } + + private static string XmlBool(bool value) => value ? "true" : "false"; + private static string MatchMmsDomain( string generatedInst, string previousIedName, @@ -153,4 +217,4 @@ private static string SafeXmlName(string value) return "LIVE_IED"; return char.IsLetter(result[0]) || result[0] == '_' ? result : $"_{result}"; } -} +} \ No newline at end of file From 9048d1cc073f82c9b3aae69c8b2b14a04bbbd03f Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 16:21:28 +0700 Subject: [PATCH 7/8] Test exact RCB bit-string decoding --- .../Mms/MmsReportControlFieldCodecTests.cs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs index 4f07e90..69b8993 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs @@ -25,4 +25,45 @@ public void OptionalFields_Encodes_Event_Diagnostics_And_ConfRev() Assert.Equal(MmsDataKind.BitString, value.Kind); Assert.Equal(new byte[] { 6, 0x7C, 0x80 }, value.RawValue); } -} + + [Fact] + public void TriggerOptions_Decodes_Gi_Only_From_Live_Rendered_BitString() + { + var flags = MmsReportControlFieldCodec.DecodeTriggerOptions("bits(08, unused=2)"); + + Assert.False(flags.DataChange); + Assert.False(flags.QualityChange); + Assert.False(flags.DataUpdate); + Assert.False(flags.Integrity); + Assert.True(flags.GeneralInterrogation); + Assert.False(flags.ApplicationTrigger); + } + + [Fact] + public void OptionalFields_Decodes_BufferOverflow_Only_From_Live_Rendered_BitString() + { + var flags = MmsReportControlFieldCodec.DecodeOptionalFields("bits(0200, unused=6)"); + + Assert.False(flags.SequenceNumber); + Assert.False(flags.ReportTimestamp); + Assert.False(flags.ReasonForInclusion); + Assert.False(flags.DataSetName); + Assert.False(flags.DataReference); + Assert.True(flags.BufferOverflow); + Assert.False(flags.EntryId); + Assert.False(flags.ConfigurationRevision); + Assert.False(flags.Segmentation); + } + + [Fact] + public void Decoders_Also_Accept_Engineer_Readable_Names() + { + var trigger = MmsReportControlFieldCodec.DecodeTriggerOptions("dchg qchg"); + var optional = MmsReportControlFieldCodec.DecodeOptionalFields("sequence-number buffer-overflow"); + + Assert.True(trigger.DataChange); + Assert.True(trigger.QualityChange); + Assert.True(optional.SequenceNumber); + Assert.True(optional.BufferOverflow); + } +} \ No newline at end of file From 9e429b31ac3a692c8839d4cd2318e4b935a4a124 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 20 Jul 2026 16:21:59 +0700 Subject: [PATCH 8/8] Test exact live RCB option export --- .../Scl/LiveIedSclRcbOptionFidelityTests.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/LiveIedSclRcbOptionFidelityTests.cs diff --git a/tests/AR.Iec61850.Tests/Scl/LiveIedSclRcbOptionFidelityTests.cs b/tests/AR.Iec61850.Tests/Scl/LiveIedSclRcbOptionFidelityTests.cs new file mode 100644 index 0000000..ab911ce --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/LiveIedSclRcbOptionFidelityTests.cs @@ -0,0 +1,103 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class LiveIedSclRcbOptionFidelityTests +{ + [Fact] + public void Authoritative_Exporter_Preserves_Gi_Only_And_BufferOverflow_Only() + { + var model = new LiveIedModelDiscoveryDocument + { + IedName = "OCR7SJ8", + Host = "1.110.1.1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "OCR7SJ8Application", + Inst = "Application", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", + LnClass = "LLN0", + ProposedLnTypeId = "LN_LLN0" + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "OCR7SJ8Application/LLN0.DataSet", + Domain = "OCR7SJ8Application", + LogicalNode = "LLN0", + Name = "DataSet", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = "OCR7SJ8Application/LLN0.Mod.stVal", + FunctionalConstraint = "ST" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "OCR7SJ8Application/LLN0.RP.urcbD010101", + Domain = "OCR7SJ8Application", + LogicalNode = "LLN0", + Name = "urcbD010101", + Buffered = false, + DataSetReference = "OCR7SJ8Application/LLN0.DataSet", + ConfRev = "1", + TriggerOptions = "bits(08, unused=2)", + OptionalFields = "bits(0200, unused=6)" + } + ] + }; + var options = new LiveIedSclExportOptions + { + Profile = "full-model", + SchemaProfile = SclSchemaProfile.Edition2V31, + IedNameOverride = "OCR7SJ8" + }; + + var generated = LiveIedSclExporter.BuildDocument(model, options); + var document = AuthoritativeLiveIedSclExporter.ApplyReportControlConfiguration( + generated, + model, + options.ResolvedSchemaProfile); + + var root = Assert.IsType(document.Root); + var ns = root.Name.Namespace; + var report = Assert.Single(root.Descendants(ns + "ReportControl")); + var trigger = Assert.Single(report.Elements(ns + "TrgOps")); + Assert.Equal("false", (string?)trigger.Attribute("dchg")); + Assert.Equal("false", (string?)trigger.Attribute("qchg")); + Assert.Equal("false", (string?)trigger.Attribute("dupd")); + Assert.Equal("false", (string?)trigger.Attribute("period")); + Assert.Equal("true", (string?)trigger.Attribute("gi")); + + var optional = Assert.Single(report.Elements(ns + "OptFields")); + Assert.Equal("false", (string?)optional.Attribute("seqNum")); + Assert.Equal("false", (string?)optional.Attribute("timeStamp")); + Assert.Equal("false", (string?)optional.Attribute("reasonCode")); + Assert.Equal("false", (string?)optional.Attribute("dataSet")); + Assert.Equal("false", (string?)optional.Attribute("dataRef")); + Assert.Equal("true", (string?)optional.Attribute("bufOvfl")); + Assert.Equal("false", (string?)optional.Attribute("entryID")); + Assert.Equal("false", (string?)optional.Attribute("configRef")); + Assert.Equal("false", (string?)optional.Attribute("segmentation")); + } +} \ No newline at end of file