From 18e148057c0bbb6f284a8ff11988f3843a38f3b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:28:50 +0700 Subject: [PATCH 01/11] feat(m2.1): introduce typed fail-closed IEC quality evidence --- Protocol/Mms/MmsQualityDecoder.cs | 153 ++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 40 deletions(-) diff --git a/Protocol/Mms/MmsQualityDecoder.cs b/Protocol/Mms/MmsQualityDecoder.cs index 14504c54f..e82ebafd0 100644 --- a/Protocol/Mms/MmsQualityDecoder.cs +++ b/Protocol/Mms/MmsQualityDecoder.cs @@ -1,57 +1,130 @@ using System; using System.Collections.Generic; +using System.Globalization; namespace ArIED61850Tester.Protocol.Mms; -public static class MmsQualityDecoder +/// Thirteen verified IEC 61850 Quality bits, not inferred defaults. +public sealed record MmsQualityEvidence( + string Validity, bool Overflow, bool OutOfRange, bool BadReference, + bool Oscillatory, bool Failure, bool OldData, bool Inconsistent, + bool Inaccurate, bool Substituted, bool Test, bool OperatorBlocked) { - public static string Decode(ReadOnlySpan bitStringPayload) - { - if (bitStringPayload.Length < 2) - return "Quality: empty"; + public string Source => Substituted ? "substituted" : "process"; - var bits = ExpandBits(bitStringPayload[1..]); - var validity = bits.Count >= 2 - ? ((bits[0] ? 2 : 0) | (bits[1] ? 1 : 0)) - : 0; + public string FormatField(string field) => field switch + { + "" => Validity, + "Validity" => Validity.ToLowerInvariant(), + "Source" => Source, + "Overflow" => Flag(Overflow), + "OutOfRange" => Flag(OutOfRange), + "BadReference" => Flag(BadReference), + "Oscillatory" => Flag(Oscillatory), + "Failure" => Flag(Failure), + "OldData" => Flag(OldData), + "Inconsistent" => Flag(Inconsistent), + "Inaccurate" => Flag(Inaccurate), + "Test" => Flag(Test), + "OperatorBlocked" => Flag(OperatorBlocked), + _ => "Not exposed" + }; - var validityText = validity switch + public string Summary + { + get { - 0 => "Good", - 1 => "Invalid", - 2 => "Reserved", - 3 => "Questionable", - _ => "Unknown" - }; - - var details = new List { validityText }; - Add(details, bits, 2, "Overflow"); - Add(details, bits, 3, "OutOfRange"); - Add(details, bits, 4, "BadReference"); - Add(details, bits, 5, "Oscillatory"); - Add(details, bits, 6, "Failure"); - Add(details, bits, 7, "OldData"); - Add(details, bits, 8, "Inconsistent"); - Add(details, bits, 9, "Inaccurate"); - Add(details, bits, 10, "Substituted"); - Add(details, bits, 11, "Test"); - Add(details, bits, 12, "OperatorBlocked"); - return string.Join(" / ", details); + var parts = new List { Validity }; + Add(parts, Overflow, "Overflow"); + Add(parts, OutOfRange, "OutOfRange"); + Add(parts, BadReference, "BadReference"); + Add(parts, Oscillatory, "Oscillatory"); + Add(parts, Failure, "Failure"); + Add(parts, OldData, "OldData"); + Add(parts, Inconsistent, "Inconsistent"); + Add(parts, Inaccurate, "Inaccurate"); + Add(parts, Substituted, "Substituted"); + Add(parts, Test, "Test"); + Add(parts, OperatorBlocked, "OperatorBlocked"); + return string.Join(" / ", parts); + } } - private static List ExpandBits(ReadOnlySpan bytes) + private static string Flag(bool value) => value ? "true" : "false"; + private static void Add(List parts, bool enabled, string name) { - var result = new List(bytes.Length * 8); - foreach (var b in bytes) - { - for (var bit = 7; bit >= 0; bit--) - result.Add(((b >> bit) & 0x01) != 0); - } - return result; + if (enabled) parts.Add(name); + } +} + +/// +/// Existing MMS Quality decoder, now fail-closed on width and unused-bit +/// metadata. The rendered-value adapter is used only for schema-verified q. +/// +public static class MmsQualityDecoder +{ + public static string Decode(ReadOnlySpan bitStringPayload) + => TryDecode(bitStringPayload, out var quality) + ? quality.Summary : "Quality: unavailable"; + + public static bool TryDecode(ReadOnlySpan payload, out MmsQualityEvidence quality) + { + quality = null!; + // Quality has exactly 13 significant bits. Reject nonzero padding. + if (payload.Length != 3 || payload[0] != 3 || (payload[2] & 0x07) != 0) + return false; + + var validity = (Bit(payload, 0) ? 2 : 0) | (Bit(payload, 1) ? 1 : 0); + quality = new MmsQualityEvidence( + validity switch + { + 0 => "Good", + 1 => "Invalid", + 2 => "Reserved", + _ => "Questionable" + }, + Bit(payload, 2), Bit(payload, 3), Bit(payload, 4), + Bit(payload, 5), Bit(payload, 6), Bit(payload, 7), + Bit(payload, 8), Bit(payload, 9), Bit(payload, 10), + Bit(payload, 11), Bit(payload, 12)); + return true; } - private static void Add(List details, List bits, int index, string label) + private static bool Bit(ReadOnlySpan payload, int index) + => (payload[1 + index / 8] & (0x80 >> (index % 8))) != 0; + + /// + /// Adapter for the already existing MMS renderer's bits(0000,unused=3) + /// representation. It does not decide which references have Quality type. + /// + public static bool TryDecodeRendered(string? rendered, out MmsQualityEvidence quality) { - if (bits.Count > index && bits[index]) details.Add(label); + quality = null!; + if (string.IsNullOrWhiteSpace(rendered)) + return false; + + var text = rendered.AsSpan().Trim(); + if (!text.StartsWith("bits(".AsSpan(), StringComparison.OrdinalIgnoreCase) || + text[^1] != ')') + return false; + var body = text[5..^1]; + var comma = body.IndexOf(','); + if (comma < 0) return false; + var hex = body[..comma].Trim(); + if (hex.StartsWith("0x".AsSpan(), StringComparison.OrdinalIgnoreCase)) + hex = hex[2..]; + var suffix = body[(comma + 1)..].Trim(); + if (!suffix.StartsWith("unused=".AsSpan(), StringComparison.OrdinalIgnoreCase) || + !byte.TryParse(suffix[7..].Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var padding) || + hex.Length != 4 || + !byte.TryParse(hex[..2], NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var first) || + !byte.TryParse(hex[2..], NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var second)) + return false; + + Span payload = stackalloc byte[] { padding, first, second }; + return TryDecode(payload, out quality); } } From 8e5be3fcf99d2438a7ce31f59a123d713f54bd45 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:29:14 +0700 Subject: [PATCH 02/11] fix(m2.1): disallow bitstring cross-type MMS Read fallbacks --- Protocol/Mms/MmsReadResponseDecoder.cs | 40 ++++++++++++++------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/Protocol/Mms/MmsReadResponseDecoder.cs b/Protocol/Mms/MmsReadResponseDecoder.cs index ba28d5dde..f3dfe8b35 100644 --- a/Protocol/Mms/MmsReadResponseDecoder.cs +++ b/Protocol/Mms/MmsReadResponseDecoder.cs @@ -47,6 +47,12 @@ public static MmsReadDecodeResult DecodeSingleVariable(byte[] presentationPayloa if (TryDecodeByHint(values, hint, out var hinted)) return Ok(hinted, hex); + // A declared Quality/Dbpos BIT STRING of the wrong width is not + // another valid process type. Never reinterpret it via generic fallback. + if ((hint.Contains("quality") || hint == "q" || hint.Contains("dbpos")) && + values.Any(v => v.Tag == 0x84)) + return Fail("MMS BIT STRING does not match the declared IEC value width.", hex); + if (TryDecodeAny(values, out var value)) return Ok(value, hex); @@ -288,24 +294,21 @@ private static bool TryDecodeInteger(List values, out object? value) private static bool TryDecodeDbpos(List values, out object? value) { - var tlv = values.LastOrDefault(v => v.Tag == 0x84 && v.Length >= 2); - if (tlv.Tag == 0x84) + var tlv = values.LastOrDefault(v => v.Tag == 0x84); + // IEC 61850 Dbpos is a two-bit MMS BIT STRING. Quality and other + // BIT STRINGs must never be interpreted as a process position. + if (tlv.Tag == 0x84 && tlv.Length == 2 && + tlv.Value.Span[0] == 6 && (tlv.Value.Span[1] & 0x3F) == 0) { - var span = tlv.Value.Span; - var bytes = span[1..]; - if (bytes.Length > 0) + var code = tlv.Value.Span[1] >> 6; + value = code switch { - var code = (bytes[0] >> 6) & 0x03; - value = code switch - { - 0 => "Intermediate [00]", - 1 => "Open [01]", - 2 => "Close [10]", - 3 => "Invalid [11]", - _ => code.ToString(CultureInfo.InvariantCulture) - }; - return true; - } + 0 => "Intermediate [00]", + 1 => "Open [01]", + 2 => "Close [10]", + _ => "Bad state [11]" + }; + return true; } value = null; return false; @@ -314,9 +317,10 @@ private static bool TryDecodeDbpos(List values, out object? value) private static bool TryDecodeQuality(List values, out object? value) { var tlv = values.LastOrDefault(v => v.Tag == 0x84 && v.Length >= 2); - if (tlv.Tag == 0x84) + if (tlv.Tag == 0x84 && + MmsQualityDecoder.TryDecode(tlv.Value.Span, out var quality)) { - value = MmsQualityDecoder.Decode(tlv.Value.Span); + value = quality.Summary; return true; } value = null; From c8963e519bc477e7fd51f5ca246e6568bf0e32ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:29:36 +0700 Subject: [PATCH 03/11] feat(m2.1): render schema-verified Quality flags without guessed values --- Services/Iec61850QualityPresentation.cs | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 Services/Iec61850QualityPresentation.cs diff --git a/Services/Iec61850QualityPresentation.cs b/Services/Iec61850QualityPresentation.cs new file mode 100644 index 000000000..65d6691cc --- /dev/null +++ b/Services/Iec61850QualityPresentation.cs @@ -0,0 +1,37 @@ +using ArIED61850Tester.Protocol.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Operator presentation over verified Quality evidence. Raw MMS text remains +/// in the observation ledger; absence of individual flags is not false. +/// +public static class Iec61850QualityPresentation +{ + public static string FormatObserved(string? observed, string evidenceField = "") + { + if (MmsQualityDecoder.TryDecodeRendered(observed, out var typed)) + return typed.FormatField(evidenceField); + + var value = (observed ?? "").Trim(); + // Legacy report projection may retain only the validity word. It is + // not evidence for any other Quality bit (including a zero bit). + var separator = value.IndexOf(" / ", StringComparison.Ordinal); + var validity = separator < 0 ? value : value[..separator]; + if (validity.Equals("Good", StringComparison.OrdinalIgnoreCase) || + validity.Equals("Invalid", StringComparison.OrdinalIgnoreCase) || + validity.Equals("Reserved", StringComparison.OrdinalIgnoreCase) || + validity.Equals("Questionable", StringComparison.OrdinalIgnoreCase)) + { + if (evidenceField == "Validity") + return validity.ToLowerInvariant(); + return evidenceField.Length == 0 ? value : "Not exposed"; + } + + // The malformed renderer text is still preserved for diagnostics, + // never presented as a trusted live IEC state. + return evidenceField.Length == 0 && + value.Length > 0 && !value.StartsWith("bits(", StringComparison.OrdinalIgnoreCase) + ? value : "Not exposed"; + } +} From c859be981b57766bea2531ae1309d8b77c24f2bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:29:53 +0700 Subject: [PATCH 04/11] feat(m2.1): project typed Quality evidence into inline Browser details --- Models/IedBrowserPresentation.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Models/IedBrowserPresentation.cs b/Models/IedBrowserPresentation.cs index 85a85a0cf..121643f85 100644 --- a/Models/IedBrowserPresentation.cs +++ b/Models/IedBrowserPresentation.cs @@ -179,6 +179,8 @@ public string Value // A typed child of a received q/t field; never treat the entire parent // value text as this individual Boolean/time-quality bit. public string EvidenceField { get; init; } = ""; + // Schema-qualified presentation type; never infer a q/t bit from a DO name. + public string EvidenceKind { get; init; } = ""; public string FunctionalConstraint { get; init; } = ""; private IReadOnlyList? _children; public IedBrowserTreeRow? NavigateTo { get; init; } From 34fd7aff32993e2856e12a231709baca9014ef0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:29:56 +0700 Subject: [PATCH 05/11] feat(m2.1): project typed Quality evidence into inline Browser details --- MainWindow.IedBrowser.cs | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 9a1bebda9..28e6d511b 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -6,6 +6,7 @@ using AR.Iec61850.Mms; using AR.Iec61850.Scl.Export; using ArIED61850Tester.Models; +using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -311,28 +312,15 @@ private void RefreshIedBrowserVisibleValues() row.Value = "Not read"; continue; } - if (row.EvidenceField.Length == 0) - { + // The parent q and its inline children share one IEC Quality + // presentation path. Unknown flags stay unavailable, never false. + if (row.EvidenceKind == "Quality") + row.Value = Iec61850QualityPresentation.FormatObserved( + live.Value, row.EvidenceField); + else if (row.EvidenceField.Length == 0) row.Value = live.Value; - continue; - } - if (row.EvidenceField == "Validity") - { - // A normalized q of Good/Invalid/Questionable is proof of - // validity, not proof of the remaining independent q bits. - var quality = live.Value.Trim(); - row.Value = quality.Equals("good", StringComparison.OrdinalIgnoreCase) || - quality.Equals("invalid", StringComparison.OrdinalIgnoreCase) || - quality.Equals("questionable", StringComparison.OrdinalIgnoreCase) - ? quality.ToLowerInvariant() : "Not exposed"; - } else - { - // The current report value formatter exposes normalized q/t - // without raw bits. Do not paint default false/true for bits - // that were not included in the model's actual value evidence. row.Value = "Not exposed"; - } } } From 251eaeb31da91be71b35a0e2f120ac296b79b199 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:30:31 +0700 Subject: [PATCH 06/11] feat(m2.1): bind q and t rows to explicit evidence kinds --- Models/IedBrowserStructuredDetails.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Models/IedBrowserStructuredDetails.cs b/Models/IedBrowserStructuredDetails.cs index 8c885ff48..1eb4d7292 100644 --- a/Models/IedBrowserStructuredDetails.cs +++ b/Models/IedBrowserStructuredDetails.cs @@ -95,6 +95,8 @@ private static IReadOnlyList BuildAt( ? attr.MmsReference : attr.ObjectReference + "." + string.Join(".", groupArray[0].parts.Take(pathIndex + 1)), + EvidenceKind = hasDirect && name == "q" ? "Quality" : + hasDirect && name == "t" ? "Timestamp" : "", FunctionalConstraint = groupArray.All(x => string.Equals(x.attr.FunctionalConstraint, attr.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)) @@ -119,7 +121,8 @@ IedBrowserDetailRow QualityField(string name, string evidence) { ValueReference = qualityReference, FunctionalConstraint = fc, - EvidenceField = evidence + EvidenceField = evidence, + EvidenceKind = "Quality" }; var details = new[] { "Overflow", "OutOfRange", "BadReference", "Oscillatory", "Failure", "OldData", "Inconsistent", "Inaccurate" }; @@ -133,7 +136,8 @@ IedBrowserDetailRow QualityField(string name, string evidence) { ValueReference = qualityReference, FunctionalConstraint = fc, - EvidenceField = name + EvidenceField = name, + EvidenceKind = "Quality" }).ToArray() }, QualityField("Source", "Source"), @@ -150,6 +154,7 @@ private static IReadOnlyList TimeChildren( { ValueReference = timestampReference, FunctionalConstraint = fc, - EvidenceField = name + EvidenceField = name, + EvidenceKind = "Timestamp" }).ToArray(); } From 2eb258aacd8379496dda37b6c1ce5363b1762407 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:31:14 +0700 Subject: [PATCH 07/11] test(m2.1): cover four validity states, quality flags and fail-closed widths --- .../M2TypedQualityEvidenceTests.cs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/ARSAS.Tests/M2TypedQualityEvidenceTests.cs diff --git a/tests/ARSAS.Tests/M2TypedQualityEvidenceTests.cs b/tests/ARSAS.Tests/M2TypedQualityEvidenceTests.cs new file mode 100644 index 000000000..7ee9a16bc --- /dev/null +++ b/tests/ARSAS.Tests/M2TypedQualityEvidenceTests.cs @@ -0,0 +1,95 @@ +using ArIED61850Tester.Protocol.Mms; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class M2TypedQualityEvidenceTests +{ + [Theory] + [InlineData(0x00, "Good")] + [InlineData(0x40, "Invalid")] + [InlineData(0x80, "Reserved")] + [InlineData(0xC0, "Questionable")] + public void WireValidity_UsesOnlyThirteenProvenBits(int first, string expected) + { + var raw = new byte[] { 3, (byte)first, 0 }; + Assert.True(MmsQualityDecoder.TryDecode(raw, out var evidence)); + Assert.Equal(expected, evidence.Validity); + Assert.Equal(expected, MmsQualityDecoder.Decode(raw)); + Assert.Equal("process", evidence.Source); + Assert.Equal("false", evidence.FormatField("OldData")); + } + + [Fact] + public void QualityFlags_AreTypedAndReadableByEveryDetailField() + { + // Validity=Good; all eleven independent detail/source flags set. + var raw = new byte[] { 3, 0x3F, 0xF8 }; + Assert.True(MmsQualityDecoder.TryDecode(raw, out var quality)); + Assert.Equal("Good", quality.Validity); + Assert.Equal("substituted", quality.Source); + foreach (var field in new[] { + "Overflow", "OutOfRange", "BadReference", "Oscillatory", + "Failure", "OldData", "Inconsistent", "Inaccurate", + "Test", "OperatorBlocked" }) + Assert.Equal("true", quality.FormatField(field)); + Assert.Equal("Good", Iec61850QualityPresentation.FormatObserved("bits(3FF8,unused=3)")); + Assert.Equal("good", Iec61850QualityPresentation.FormatObserved("bits(3FF8,unused=3)", "Validity")); + Assert.Equal("substituted", Iec61850QualityPresentation.FormatObserved("bits(3FF8,unused=3)", "Source")); + Assert.Equal("true", Iec61850QualityPresentation.FormatObserved("bits(3FF8,unused=3)", "OperatorBlocked")); + Assert.Contains("OperatorBlocked", quality.Summary); + } + + [Theory] + [InlineData("bits(0000,unused=3)", "Good")] + [InlineData("bits(4000, unused=3)", "Invalid")] + [InlineData("bits(8000,unused=3)", "Reserved")] + [InlineData("bits(C000,unused=3)", "Questionable")] + public void ExistingEngineRendererText_AdaptsToTypedEvidence(string rendered, string validity) + { + Assert.True(MmsQualityDecoder.TryDecodeRendered(rendered, out var quality)); + Assert.Equal(validity, quality.Validity); + Assert.Equal(validity, Iec61850QualityPresentation.FormatObserved(rendered)); + } + + [Theory] + [InlineData("bits(40,unused=6)")] + [InlineData("bits(0000,unused=0)")] + [InlineData("bits(0001,unused=3)")] + [InlineData("bits(0000,unused=8)")] + [InlineData("bits(000000,unused=3)")] + [InlineData("bits(FFFF,unused=3)")] + [InlineData("bits(nonsense,unused=3)")] + public void WrongWidthOrNonzeroPadding_NeverCreatesSyntheticQuality(string rendered) + { + Assert.False(MmsQualityDecoder.TryDecodeRendered(rendered, out _)); + Assert.Equal("Not exposed", Iec61850QualityPresentation.FormatObserved(rendered, "Validity")); + } + + [Fact] + public void NormalizedReportValidity_DoesNotProveMissingIndependentFlags() + { + Assert.Equal("Good", Iec61850QualityPresentation.FormatObserved("Good")); + Assert.Equal("good", Iec61850QualityPresentation.FormatObserved("Good", "Validity")); + Assert.Equal("Not exposed", Iec61850QualityPresentation.FormatObserved("Good", "Failure")); + Assert.Equal("Not exposed", Iec61850QualityPresentation.FormatObserved("Invalid / Overflow", "Overflow")); + Assert.Equal("invalid", Iec61850QualityPresentation.FormatObserved("Invalid / Overflow", "Validity")); + Assert.Equal("Quality: unavailable", MmsQualityDecoder.Decode(new byte[] { 3, 0, 1 })); + Assert.False(MmsQualityDecoder.TryDecode(new byte[] { 6, 0x40 }, out _)); + } + + [Theory] + [InlineData("bits(00,unused=6)", "Intermediate [00]")] + [InlineData("bits(40,unused=6)", "Open [01]")] + [InlineData("bits(80,unused=6)", "Close [10]")] + [InlineData("bits(C0,unused=6)", "Bad state [11]")] + public void ExistingSharedDbposFormatter_RendersAllFourCodesWithoutQualityLeak( + string raw, string expected) + { + Assert.Equal(expected, Iec61850ValueFormatter.FormatReportProcessValue( + raw, "Dbpos", "", "Position", "LD/CSWI1.Pos.stVal")); + Assert.Equal(raw, Iec61850ValueFormatter.FormatReportProcessValue( + raw, "BitString", "", "Measurement", "LD/GGIO1.RawBits")); + Assert.False(MmsQualityDecoder.TryDecodeRendered(raw, out _)); + } +} From 7c33b25d93881b17006573a4a5e4746f963ada76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:32:42 +0700 Subject: [PATCH 08/11] test(m2.1): exercise MMS decode width contract and Browser schema binding --- .../M2TypedQualityEvidenceTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/ARSAS.Tests/M2TypedQualityEvidenceTests.cs b/tests/ARSAS.Tests/M2TypedQualityEvidenceTests.cs index 7ee9a16bc..d490d5dab 100644 --- a/tests/ARSAS.Tests/M2TypedQualityEvidenceTests.cs +++ b/tests/ARSAS.Tests/M2TypedQualityEvidenceTests.cs @@ -1,4 +1,6 @@ using ArIED61850Tester.Protocol.Mms; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; using ArIED61850Tester.Services; namespace ARSAS.Tests; @@ -92,4 +94,59 @@ public void ExistingSharedDbposFormatter_RendersAllFourCodesWithoutQualityLeak( raw, "BitString", "", "Measurement", "LD/GGIO1.RawBits")); Assert.False(MmsQualityDecoder.TryDecodeRendered(raw, out _)); } + [Fact] + public void MmsRead_EnforcesDeclaredTypeInsteadOfReinterpretingWrongWidth() + { + // Minimal confirmed-response envelope; the decoder sees the exact + // MMS Data BIT STRING encoded at the normal constructed response seam. + var position = MmsReadResponseDecoder.DecodeSingleVariable( + new byte[] { 0xA1, 0x07, 0x02, 0x01, 0x01, 0x84, 0x02, 0x06, 0x40 }, "Dbpos"); + Assert.True(position.IsSuccess, position.Message); + Assert.Equal("Open [01]", position.Value); + + var quality = MmsReadResponseDecoder.DecodeSingleVariable( + new byte[] { 0xA1, 0x08, 0x02, 0x01, 0x01, 0x84, 0x03, 0x03, 0x00, 0x00 }, "Quality"); + Assert.True(quality.IsSuccess, quality.Message); + Assert.Equal("Good", quality.Value); + + var wrongDbpos = MmsReadResponseDecoder.DecodeSingleVariable( + new byte[] { 0xA1, 0x08, 0x02, 0x01, 0x01, 0x84, 0x03, 0x03, 0x00, 0x00 }, "Dbpos"); + Assert.False(wrongDbpos.IsSuccess); + Assert.Contains("width", wrongDbpos.Message, StringComparison.OrdinalIgnoreCase); + + var wrongQuality = MmsReadResponseDecoder.DecodeSingleVariable( + new byte[] { 0xA1, 0x07, 0x02, 0x01, 0x01, 0x84, 0x02, 0x06, 0x40 }, "Quality"); + Assert.False(wrongQuality.IsSuccess); + } + + [Fact] + public void BrowserAttributeRows_CarrySchemaBasedQualityEvidenceIdentity() + { + var data = new[] { + new LiveIedDataAttributeModel { + AttributePath = "Pos.q", ObjectReference = "LD/CSWI1.Pos", + MmsReference = "LD/CSWI1$ST$Pos$q", FunctionalConstraint = "ST" + }, + new LiveIedDataAttributeModel { + AttributePath = "Pos.t", ObjectReference = "LD/CSWI1.Pos", + MmsReference = "LD/CSWI1$ST$Pos$t", FunctionalConstraint = "ST" + }, + new LiveIedDataAttributeModel { + AttributePath = "Pos.stVal", ObjectReference = "LD/CSWI1.Pos", + MmsReference = "LD/CSWI1$ST$Pos$stVal", FunctionalConstraint = "ST" + } + }; + var rows = IedBrowserStructuredDetails.AttributeRows(data, "Pos"); + var q = Assert.Single(rows.Where(row => row.Name == "q [ST]")); + var t = Assert.Single(rows.Where(row => row.Name == "t [ST]")); + var process = Assert.Single(rows.Where(row => row.Name == "stVal [ST]")); + Assert.Equal("Quality", q.EvidenceKind); + Assert.Equal("Timestamp", t.EvidenceKind); + Assert.Empty(process.EvidenceKind); + var children = q.GetChildren(); + Assert.Contains(children, row => row.EvidenceKind == "Quality" && row.EvidenceField == "Validity"); + Assert.Contains(children, row => row.EvidenceKind == "Quality" && row.EvidenceField == "Source"); + Assert.All(t.GetChildren(), row => Assert.Equal("Timestamp", row.EvidenceKind)); + } + } From da3d696af772d3dfbf62e75aefb40972bce45146 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:36:56 +0700 Subject: [PATCH 09/11] refactor(m2.1): reuse canonical Quality presentation class across Browser and FAT --- Models/Iec61850ValueStatePresentation.cs | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Models/Iec61850ValueStatePresentation.cs b/Models/Iec61850ValueStatePresentation.cs index dba01b57a..012cf4842 100644 --- a/Models/Iec61850ValueStatePresentation.cs +++ b/Models/Iec61850ValueStatePresentation.cs @@ -1,3 +1,5 @@ +using ArIED61850Tester.Protocol.Mms; + namespace ArIED61850Tester.Models; /// @@ -255,6 +257,33 @@ private static bool LooksLikeAnalogAggregate(string text) /// public static class Iec61850QualityPresentation { + public static string FormatObserved(string? observed, string evidenceField = "") + { + if (MmsQualityDecoder.TryDecodeRendered(observed, out var typed)) + return typed.FormatField(evidenceField); + + var value = (observed ?? "").Trim(); + // Legacy report projection may retain only the validity word. It is + // not evidence for any other Quality bit (including a zero bit). + var separator = value.IndexOf(" / ", StringComparison.Ordinal); + var validity = separator < 0 ? value : value[..separator]; + if (validity.Equals("Good", StringComparison.OrdinalIgnoreCase) || + validity.Equals("Invalid", StringComparison.OrdinalIgnoreCase) || + validity.Equals("Reserved", StringComparison.OrdinalIgnoreCase) || + validity.Equals("Questionable", StringComparison.OrdinalIgnoreCase)) + { + if (evidenceField == "Validity") + return validity.ToLowerInvariant(); + return evidenceField.Length == 0 ? value : "Not exposed"; + } + + // The malformed renderer text is still preserved for diagnostics, + // never presented as a trusted live IEC state. + return evidenceField.Length == 0 && + value.Length > 0 && !value.StartsWith("bits(", StringComparison.OrdinalIgnoreCase) + ? value : "Not exposed"; + } + public const string Good = "Good"; public const string Attention = "Attention"; public const string Bad = "Bad"; From 405d1a8b35d592a7b23f13c9277dd2e5921b64db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:37:11 +0700 Subject: [PATCH 10/11] refactor(m2.1): remove duplicate Quality presentation helper --- Services/Iec61850QualityPresentation.cs | 37 ------------------------- 1 file changed, 37 deletions(-) delete mode 100644 Services/Iec61850QualityPresentation.cs diff --git a/Services/Iec61850QualityPresentation.cs b/Services/Iec61850QualityPresentation.cs deleted file mode 100644 index 65d6691cc..000000000 --- a/Services/Iec61850QualityPresentation.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ArIED61850Tester.Protocol.Mms; - -namespace ArIED61850Tester.Services; - -/// -/// Operator presentation over verified Quality evidence. Raw MMS text remains -/// in the observation ledger; absence of individual flags is not false. -/// -public static class Iec61850QualityPresentation -{ - public static string FormatObserved(string? observed, string evidenceField = "") - { - if (MmsQualityDecoder.TryDecodeRendered(observed, out var typed)) - return typed.FormatField(evidenceField); - - var value = (observed ?? "").Trim(); - // Legacy report projection may retain only the validity word. It is - // not evidence for any other Quality bit (including a zero bit). - var separator = value.IndexOf(" / ", StringComparison.Ordinal); - var validity = separator < 0 ? value : value[..separator]; - if (validity.Equals("Good", StringComparison.OrdinalIgnoreCase) || - validity.Equals("Invalid", StringComparison.OrdinalIgnoreCase) || - validity.Equals("Reserved", StringComparison.OrdinalIgnoreCase) || - validity.Equals("Questionable", StringComparison.OrdinalIgnoreCase)) - { - if (evidenceField == "Validity") - return validity.ToLowerInvariant(); - return evidenceField.Length == 0 ? value : "Not exposed"; - } - - // The malformed renderer text is still preserved for diagnostics, - // never presented as a trusted live IEC state. - return evidenceField.Length == 0 && - value.Length > 0 && !value.StartsWith("bits(", StringComparison.OrdinalIgnoreCase) - ? value : "Not exposed"; - } -} From 76be1df01d1e7933f1e4403ea23ba848e751ec9d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 06:37:14 +0700 Subject: [PATCH 11/11] refactor(m2.1): resolve Browser quality through existing model presentation --- MainWindow.IedBrowser.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 28e6d511b..0f35d44a8 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -6,7 +6,6 @@ using AR.Iec61850.Mms; using AR.Iec61850.Scl.Export; using ArIED61850Tester.Models; -using ArIED61850Tester.Services; namespace ArIED61850Tester;