From dc32213efbe89f61adb8258f009949e6bca8227e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:47:37 +0700 Subject: [PATCH 01/23] feat(iec10x): add typed COT, quality and command response semantics (#39) --- .../Protocol/Iec10x/Iec10xSemanticDetails.cs | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs new file mode 100644 index 0000000..aab3d5f --- /dev/null +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs @@ -0,0 +1,200 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; + +namespace ARIEC60870.Master.Protocol.Iec10x; + +/// On-wire IEC-101/104 cause flags, never inferred from the English COT label. +public sealed record Iec10xCauseDetails(byte Raw, int Code, bool Negative, bool Test, int? OriginatorAddress) +{ + public string Phase => Code switch + { + 5 => "Requested", + 6 => "Activation request", + 7 => "Activation confirmation", + 8 => "Deactivation request", + 9 => "Deactivation confirmation", + 10 => "Activation termination", + 11 => "Return information: remote command", + 12 => "Return information: local command", + 44 => "Unknown Type ID", + 45 => "Unknown COT", + 46 => "Unknown common address", + 47 => "Unknown information object address", + _ => Iec10xAsduDecoder.CauseName(Code) + }; + + public string Outcome => Negative + ? "NEGATIVE (P/N=1); request rejected/negatively confirmed" + : Code is 44 or 45 or 46 or 47 + ? "Protocol error reported; inspect Type/COT/CA/IOA" + : Code == 7 ? "Activation confirmed at application level; physical operation NOT proven" + : Code == 10 ? "Activation terminated; verify returned process state separately" + : "No application success/failure conclusion from this COT alone"; + + public string Detail => + $"COT={Code} ({Phase}); raw=0x{Raw:X2}; P/N={(Negative ? 1 : 0)}; TEST={(Test ? 1 : 0)}; " + + $"OA={(OriginatorAddress.HasValue ? OriginatorAddress.Value.ToString(CultureInfo.InvariantCulture) : "N/A (1-octet COT)")}. {Outcome}."; +} + +public enum Iec10xQualityKind { Siq, Diq, Qds, Bcr } + +/// Typed information-object quality; an inapplicable bit is null, not false. +public sealed record Iec10xQualityDetails(Iec10xQualityKind Kind, byte Raw) +{ + public bool Invalid => (Raw & 0x80) != 0; + public bool? Blocked => Kind == Iec10xQualityKind.Bcr ? null : (Raw & 0x10) != 0; + public bool? Substituted => Kind == Iec10xQualityKind.Bcr ? null : (Raw & 0x20) != 0; + public bool? NotTopical => Kind == Iec10xQualityKind.Bcr ? null : (Raw & 0x40) != 0; + public bool? Overflow => Kind == Iec10xQualityKind.Qds ? (Raw & 0x01) != 0 : null; + public bool? Carry => Kind == Iec10xQualityKind.Bcr ? (Raw & 0x20) != 0 : null; + public bool? Adjusted => Kind == Iec10xQualityKind.Bcr ? (Raw & 0x40) != 0 : null; + public int? CounterSequence => Kind == Iec10xQualityKind.Bcr ? Raw & 0x1F : null; + + public IReadOnlyList ActiveBadges + { + get + { + var badges = new List(5); + if (Invalid) badges.Add("INVALID"); + if (Overflow == true) badges.Add("OVF"); + if (Carry == true) badges.Add("CARRY"); + if (NotTopical == true) badges.Add("NT"); + if (Blocked == true) badges.Add("BLK"); + if (Substituted == true) badges.Add("SUB"); + if (Adjusted == true) badges.Add("ADJ"); + return badges.Count == 0 ? new[] { "Good" } : badges; + } + } + + public string DisplayText => string.Join(", ", ActiveBadges.Select(b => b switch + { + "OVF" => "Overflow", "CARRY" => "Carry", "BLK" => "Blocked", + "SUB" => "Substituted", "NT" => "Not topical", + "ADJ" => "Adjusted", "INVALID" => "Invalid", _ => b + })); + + public string BitDetails => + $"{Kind} raw=0x{Raw:X2}; IV={(Invalid ? 1 : 0)}; " + + $"BL={Bit(Blocked)}; SB={Bit(Substituted)}; NT={Bit(NotTopical)}; " + + $"OV={Bit(Overflow)}; CY={Bit(Carry)}; CA={Bit(Adjusted)}" + + (CounterSequence.HasValue ? $"; SQ={CounterSequence.Value}" : string.Empty); + + private static string Bit(bool? value) => value.HasValue ? (value.Value ? "1" : "0") : "N/A"; + + public static Iec10xQualityDetails? Decode(byte type, IReadOnlyList data) + { + var (kind, index) = type switch + { + 1 or 2 or 30 => (Iec10xQualityKind.Siq, 0), + 3 or 4 or 31 => (Iec10xQualityKind.Diq, 0), + 5 or 6 or 32 => (Iec10xQualityKind.Qds, 1), + 7 or 8 or 33 => (Iec10xQualityKind.Qds, 4), + 9 or 10 or 11 or 12 or 34 or 35 => (Iec10xQualityKind.Qds, 2), + 13 or 14 or 36 => (Iec10xQualityKind.Qds, 4), + 15 or 16 or 37 => (Iec10xQualityKind.Bcr, 4), + _ => (Iec10xQualityKind.Qds, -1) + }; + return index >= 0 && data.Count > index ? new Iec10xQualityDetails(kind, data[index]) : null; + } +} + +/// +/// Exact command qualifier and application response semantics from the ASDU. +/// The value indicates a PROTOCOL confirmation only, never verified switchgear motion. +/// Byte offsets are relative to the ASDU, not FT1.2/APDU framing. +/// +public sealed record Iec10xCommandDetails( + byte TypeId, int InformationObjectAddress, string CommandKind, + string ValueMeaning, string QualifierName, byte? QualifierRaw, + int? QualifierAsduOffset, bool? Select, int? QualifierValue, + Iec10xCauseDetails Cause) +{ + public string Operation => Select is null ? "N/A" : Select.Value ? "SELECT" : "EXECUTE"; + + public string Detail => + $"{CommandKind}; IOA={InformationObjectAddress}; {ValueMeaning}; " + + $"operation={Operation}; {QualifierName}=" + + (QualifierRaw.HasValue ? $"0x{QualifierRaw.Value:X2}" : "N/A") + + (QualifierValue.HasValue ? $" (qualifier={QualifierValue.Value})" : "") + + (QualifierAsduOffset.HasValue ? $"; ASDU byte offset={QualifierAsduOffset.Value}" : "") + + $". {Cause.Detail}"; + + public static Iec10xCommandDetails? Decode(byte type, Iec10xInformationObject? obj, Iec10xCauseDetails cause) + { + if (obj is null) return null; + var data = obj.RawBytes; + var kind = type switch + { + 45 or 58 => "C_SC single command (SCO)", + 46 or 59 => "C_DC double command (DCO)", + 47 or 60 => "C_RC regulating step (RCO)", + 48 or 61 => "C_SE normalized setpoint (QOS)", + 49 or 62 => "C_SE scaled setpoint (QOS)", + 50 or 63 => "C_SE float setpoint (QOS)", + 100 => "C_IC general interrogation (QOI)", + 101 => "C_CI counter interrogation (QCC)", + 102 => "C_RD read command", + 103 => "C_CS clock synchronization", + 104 or 107 => "C_TS test command", + 105 => "C_RP reset process", + _ => null + }; + if (kind is null) return null; + + var index = type switch + { + 45 or 46 or 47 or 58 or 59 or 60 or 100 or 101 or 105 => 0, + 48 or 49 or 61 or 62 => 2, + 50 or 63 => 4, + _ => -1 + }; + byte? q = index >= 0 && data.Count > index ? data[index] : null; + var isOperation = type is 45 or 46 or 47 or 48 or 49 or 50 or 58 or 59 or 60 or 61 or 62 or 63; + bool? select = isOperation && q.HasValue ? (q.Value & 0x80) != 0 : null; + int? qualifier = q.HasValue ? type switch + { + 45 or 46 or 47 or 58 or 59 or 60 => (q.Value >> 2) & 0x1F, + 48 or 49 or 50 or 61 or 62 or 63 => q.Value & 0x7F, + 101 => q.Value & 0x3F, + 100 or 105 => q.Value, + _ => null + } : null; + var qualifierName = type switch + { + 45 or 46 or 47 or 58 or 59 or 60 => "QU (bits 2..6), S/E (bit 7)", + 48 or 49 or 50 or 61 or 62 or 63 => "QOS (QL bits 0..6, S/E bit 7)", + 100 => "QOI (interrogation group)", + 101 => "QCC (RQT bits 0..5, FRZ bits 6..7)", + 105 => "QRP (reset qualifier)", + _ => "no qualifier byte" + }; + var value = type switch + { + 45 or 58 when q.HasValue => $"SCS={(q.Value & 1)} ({((q.Value & 1) == 1 ? "ON" : "OFF")})", + 46 or 59 when q.HasValue => $"DCS={(q.Value & 3)} ({DoubleState(q.Value & 3)})", + 47 or 60 when q.HasValue => $"RCS={(q.Value & 3)} ({StepState(q.Value & 3)})", + 100 when q.HasValue => q.Value == 20 ? "Station GI" : q.Value is >= 21 and <= 36 ? $"Group GI #{q.Value - 20}" : $"QOI={q.Value}", + 101 when q.HasValue => $"RQT={q.Value & 0x3F}, FRZ={(q.Value >> 6) & 3}", + 102 => "Read addressed IOA", + 103 => "Clock synchronization; inspect CP56 timestamp and validity", + 104 or 107 => "Test command; inspect test sequence counter and timestamp where present", + 105 when q.HasValue => $"Reset process qualifier={q.Value}", + _ => obj.ValueText + }; + return new Iec10xCommandDetails( + type, obj.InformationObjectAddress, kind, value, qualifierName, q, + q.HasValue ? obj.InformationElementOffset + index : null, + select, qualifier, cause); + } + + private static string DoubleState(int code) => code switch + { + 1 => "OFF", 2 => "ON", 0 => "Not permitted / intermediate", _ => "Not permitted / indeterminate" + }; + private static string StepState(int code) => code switch + { + 1 => "LOWER", 2 => "HIGHER", 0 => "Not permitted", _ => "Not permitted" + }; +} From 0d8086b121c6b2e9f952429997e4965934cfb43d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:48:00 +0700 Subject: [PATCH 02/23] feat(iec10x): preserve command COT, typed quality and ASDU offsets in decoded objects (#39) --- src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs index 30d86a4..15354e3 100644 --- a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs @@ -11,6 +11,8 @@ public sealed class Iec10xAsduDecode public bool IsSequence { get; init; } public int ObjectCount { get; init; } public int CauseOfTransmission { get; init; } + public Iec10xCauseDetails? CauseDetails { get; init; } + public Iec10xCommandDetails? CommandDetails { get; init; } public string CauseName { get; init; } = string.Empty; public int OriginatorAddress { get; init; } public bool IsTest { get; init; } @@ -60,6 +62,9 @@ public sealed class Iec10xInformationObject public string ValueText { get; init; } = string.Empty; public string EngineeringValue { get; init; } = string.Empty; public string QualityText { get; init; } = string.Empty; + public Iec10xQualityDetails? QualityDetails { get; init; } + public int InformationObjectOffset { get; init; } = -1; + public int InformationElementOffset { get; init; } = -1; public string TimestampText { get; init; } = string.Empty; public string ElementSummary { get; init; } = string.Empty; public IReadOnlyList RawBytes { get; init; } = Array.Empty(); From 3492c4b37c47150b4a1d58d02fac60ec6d384bcb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:48:03 +0700 Subject: [PATCH 03/23] feat(iec10x): preserve command COT, typed quality and ASDU offsets in decoded objects (#39) --- .../Protocol/Iec10x/Iec10xAsduDecoder.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs index 6005f4d..43459d3 100644 --- a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs @@ -57,6 +57,8 @@ public Iec10xAsduDecode Decode(IReadOnlyList bytes) var objects = DecodeObjects(type, raw, offset, objectCount, sq, issues); var first = objects.FirstOrDefault(); + var causeDetails = new Iec10xCauseDetails(cotRaw, cot, negative, test, _cotSize == 2 ? oa : null); + var commandDetails = Iec10xCommandDetails.Decode(type, first, causeDetails); return new Iec10xAsduDecode { @@ -67,6 +69,8 @@ public Iec10xAsduDecode Decode(IReadOnlyList bytes) IsSequence = sq, ObjectCount = objectCount, CauseOfTransmission = cot, + CauseDetails = causeDetails, + CommandDetails = commandDetails, CauseName = CauseName(cot), OriginatorAddress = oa, IsTest = test, @@ -100,6 +104,7 @@ private IReadOnlyList DecodeObjects(byte type, byte[] r int? sequenceBaseIoa = null; for (var i = 0; i < objectCount; i++) { + var objectOffset = sq && i > 0 ? -1 : offset; if (sq && i > 0) { if (!sequenceBaseIoa.HasValue) @@ -120,6 +125,7 @@ private IReadOnlyList DecodeObjects(byte type, byte[] r } var ioa = sq ? sequenceBaseIoa!.Value + i : sequenceBaseIoa!.Value; + var elementOffset = offset; var remaining = raw.Length - offset; var take = dataLength >= 0 ? Math.Min(dataLength, Math.Max(0, remaining)) : Math.Max(0, remaining); var elementRaw = raw.Skip(offset).Take(take).ToArray(); @@ -128,7 +134,7 @@ private IReadOnlyList DecodeObjects(byte type, byte[] r if (remaining < dataLength) { var objectIssues = new[] { $"Information element too short. Expected={dataLength}, actual={remaining}." }; - objects.Add(BuildObject(type, i + 1, ioa, elementRaw, objectIssues)); + objects.Add(BuildObject(type, i + 1, ioa, elementRaw, objectOffset, elementOffset, objectIssues)); break; } @@ -139,7 +145,7 @@ private IReadOnlyList DecodeObjects(byte type, byte[] r offset += take; } - objects.Add(BuildObject(type, i + 1, ioa, elementRaw, Array.Empty())); + objects.Add(BuildObject(type, i + 1, ioa, elementRaw, objectOffset, elementOffset, Array.Empty())); } if (offset < raw.Length && dataLength >= 0) @@ -150,17 +156,21 @@ private IReadOnlyList DecodeObjects(byte type, byte[] r return objects; } - private static Iec10xInformationObject BuildObject(byte type, int index, int ioa, byte[] data, IReadOnlyList existingIssues) + private static Iec10xInformationObject BuildObject(byte type, int index, int ioa, byte[] data, int objectOffset, int elementOffset, IReadOnlyList existingIssues) { var issues = existingIssues.ToList(); var value = DecodeValueText(type, data, issues); - var quality = DecodeQualityText(type, data); + var qualityDetails = Iec10xQualityDetails.Decode(type, data); + var quality = qualityDetails?.DisplayText ?? string.Empty; var time = DecodeTimestampText(type, data); return new Iec10xInformationObject { Index = index, InformationObjectAddress = ioa, + InformationObjectOffset = objectOffset, + InformationElementOffset = elementOffset, + QualityDetails = qualityDetails, ValueText = value, EngineeringValue = value, QualityText = quality, From c7359ca3b5a109cb63d56b4e726b21afac78b1fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:48:47 +0700 Subject: [PATCH 04/23] feat(iec10x): project typed command/response evidence from Master and Monitor (#39) --- src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs b/src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs index 0ad11f2..7b970e9 100644 --- a/src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs +++ b/src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using ARIEC60870.Core.Model; +using ARIEC60870.Master.Protocol.Iec10x; namespace ARIEC60870.Master.Model; @@ -58,6 +59,9 @@ public sealed class Iec103MasterEvidenceEvent public int? InformationObjectAddress { get; init; } public string ObjectSummary { get; init; } = string.Empty; public string QualityText { get; init; } = string.Empty; + public Iec10xCauseDetails? CauseDetails { get; init; } + public Iec10xQualityDetails? QualityDetails { get; init; } + public Iec10xCommandDetails? CommandDetails { get; init; } public bool? Acd => Frame?.LinkControl?.Acd; public bool? Dfc => Frame?.LinkControl?.Dfc; From 70b8cd5000c3c496cd51e1fc45412fcbe33f8de6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:48:50 +0700 Subject: [PATCH 05/23] feat(iec10x): project typed command/response evidence from Master and Monitor (#39) --- src/ARIEC60870.Master/Iec101MasterSession.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ARIEC60870.Master/Iec101MasterSession.cs b/src/ARIEC60870.Master/Iec101MasterSession.cs index 4996bce..89a8a92 100644 --- a/src/ARIEC60870.Master/Iec101MasterSession.cs +++ b/src/ARIEC60870.Master/Iec101MasterSession.cs @@ -681,6 +681,9 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, IsSequenceAsdu = asdu?.IsSequence, ObjectCount = asdu?.ObjectCount, CauseOfTransmission = asdu?.CauseOfTransmission, + CauseDetails = asdu?.CauseDetails, + CommandDetails = asdu?.CommandDetails, + QualityDetails = asdu?.FirstObject?.QualityDetails, CauseName = asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = asdu?.OriginatorAddress, CommonAddressNumber = asdu?.CommonAddress, @@ -805,6 +808,9 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, IsSequenceAsdu = asdu?.IsSequence, ObjectCount = asdu?.ObjectCount, CauseOfTransmission = asdu?.CauseOfTransmission, + CauseDetails = asdu?.CauseDetails, + CommandDetails = asdu?.CommandDetails, + QualityDetails = asdu?.FirstObject?.QualityDetails, CauseName = asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = asdu?.OriginatorAddress, CommonAddressNumber = asdu?.CommonAddress, @@ -1076,6 +1082,9 @@ private void AddEvent(Iec103MasterEvidenceEvent item) IsSequenceAsdu = item.IsSequenceAsdu, ObjectCount = item.ObjectCount, CauseOfTransmission = item.CauseOfTransmission, + CauseDetails = item.CauseDetails, + CommandDetails = item.CommandDetails, + QualityDetails = item.QualityDetails, CauseName = item.CauseName, OriginatorAddress = item.OriginatorAddress, CommonAddressNumber = item.CommonAddressNumber, From 3a572a97750fb3f7c1591bb0e0ccba9aaaaa279b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:48:54 +0700 Subject: [PATCH 06/23] feat(iec10x): project typed command/response evidence from Master and Monitor (#39) --- src/ARIEC60870.Master/Iec104ClientSession.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ARIEC60870.Master/Iec104ClientSession.cs b/src/ARIEC60870.Master/Iec104ClientSession.cs index f5681c0..c064452 100644 --- a/src/ARIEC60870.Master/Iec104ClientSession.cs +++ b/src/ARIEC60870.Master/Iec104ClientSession.cs @@ -256,6 +256,9 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, IsSequenceAsdu = decoded.Asdu?.IsSequence, ObjectCount = decoded.Asdu?.ObjectCount, CauseOfTransmission = decoded.Asdu?.CauseOfTransmission, + CauseDetails = decoded.Asdu?.CauseDetails, + CommandDetails = decoded.Asdu?.CommandDetails, + QualityDetails = decoded.Asdu?.FirstObject?.QualityDetails, CauseName = decoded.Asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = decoded.Asdu?.OriginatorAddress, CommonAddressNumber = decoded.Asdu?.CommonAddress, @@ -333,6 +336,9 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, IsSequenceAsdu = decoded.Asdu?.IsSequence, ObjectCount = decoded.Asdu?.ObjectCount, CauseOfTransmission = decoded.Asdu?.CauseOfTransmission, + CauseDetails = decoded.Asdu?.CauseDetails, + CommandDetails = decoded.Asdu?.CommandDetails, + QualityDetails = decoded.Asdu?.FirstObject?.QualityDetails, CauseName = decoded.Asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = decoded.Asdu?.OriginatorAddress, CommonAddressNumber = decoded.Asdu?.CommonAddress, @@ -528,6 +534,9 @@ private void AddEvent(Iec103MasterEvidenceEvent item) IsSequenceAsdu = item.IsSequenceAsdu, ObjectCount = item.ObjectCount, CauseOfTransmission = item.CauseOfTransmission, + CauseDetails = item.CauseDetails, + CommandDetails = item.CommandDetails, + QualityDetails = item.QualityDetails, CauseName = item.CauseName, OriginatorAddress = item.OriginatorAddress, CommonAddressNumber = item.CommonAddressNumber, From a929b2addf8ca4d2d7ed5ae04860cc051b4debc2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:48:59 +0700 Subject: [PATCH 07/23] feat(iec10x): project typed command/response evidence from Master and Monitor (#39) --- src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs index e7be363..2e8a451 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs @@ -667,6 +667,9 @@ private void FeedPassiveFrameToExistingViews(PassiveObservedFrame seen) IsSequenceAsdu = decode?.IsSequence, ObjectCount = decode?.ObjectCount, CauseOfTransmission = decode?.CauseOfTransmission, + CauseDetails = decode?.CauseDetails, + CommandDetails = decode?.CommandDetails, + QualityDetails = first?.QualityDetails, CauseName = decode?.CauseName ?? "", OriginatorAddress = decode?.OriginatorAddress, CommonAddressNumber = decode?.CommonAddress, From 4fa6f538c93cd2911042d49bc63185829c1c47f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:50:23 +0700 Subject: [PATCH 08/23] feat(trace): typed command response and COT detail, normalize HEX across Master/Passive (#39) --- .../Features/MainWindow.FrameInspector.cs | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs index a318f83..65cb764 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs @@ -171,6 +171,13 @@ private static string BuildCompactFrameExplanation(EvidenceRow row) parts.Add($"Value: {row.SemanticState}."); } + if (row.Source.CauseDetails is { } cot) + parts.Add("COT / RTU response: " + cot.Detail); + if (row.Source.CommandDetails is { } cmd) + parts.Add("Command / response: " + cmd.Detail); + if (row.Source.QualityDetails is { } quality) + parts.Add("Quality bits: " + quality.BitDetails); + parts.Add(row.ProtocolMode switch { "104" => $"Protocol: IEC-104 {row.Direction}, APCI={row.ApciFormat}, NS={row.SendSequence}, NR={row.ReceiveSequence}, Type ID={row.TypeIdName}, COT={row.CotDisplay}, CA={row.CommonAddress}, IOA={row.IoAddress}.", @@ -262,6 +269,8 @@ private static IEnumerable BuildProtocolMapLines(EvidenceRow ro { yield return new ProtocolMapLine("asdu", "ASDU header", BuildAsduHeaderMeaning(row), string.Join(" ", bytes.Skip(6).Take(Math.Min(6, Math.Max(0, bytes.Length - 6))))); yield return new ProtocolMapLine("object", "CA / IOA", BuildSignalAddressMeaning(row), row.ProtocolAddress); + if (row.Source.CommandDetails is { } cmd104) + yield return new ProtocolMapLine("command", "Command / RTU response", cmd104.Detail, CommandEvidence(row, bytes, 6)); if (!string.IsNullOrWhiteSpace(row.SemanticState)) { yield return new ProtocolMapLine("payload", "Information element", BuildPayloadMeaning(row), row.SemanticState); @@ -276,6 +285,8 @@ private static IEnumerable BuildProtocolMapLines(EvidenceRow ro yield return new ProtocolMapLine("control", "Link control", BuildControlMeaning(row), BuildControlEvidence(row, string.Join(" ", bytes.Skip(4).Take(2)))); yield return new ProtocolMapLine("asdu", "ASDU header", BuildAsduHeaderMeaning(row), string.Join(" ", bytes.Skip(6).Take(Math.Min(6, Math.Max(0, bytes.Length - 8))))); yield return new ProtocolMapLine("object", "Information object address", BuildSignalAddressMeaning(row), row.ProtocolAddress); + if (row.Source.CommandDetails is { } cmd101) + yield return new ProtocolMapLine("command", "Command / RTU response", cmd101.Detail, CommandEvidence(row, bytes, 5 + (row.Source.Frame?.LinkAddressSize ?? 1))); if (!string.IsNullOrWhiteSpace(row.SemanticState)) { yield return new ProtocolMapLine("payload", "Information element", BuildPayloadMeaning(row), row.SemanticState); @@ -324,6 +335,14 @@ private static IEnumerable BuildProtocolMapLines(EvidenceRow ro yield return new ProtocolMapLine("raw", $"Raw {row.ProtocolName} bytes", "The analyzer preserved this frame as raw evidence, but it could not classify it into the expected protocol structure.", string.Join(" ", bytes)); } + private static string CommandEvidence(EvidenceRow row, string[] bytes, int asduStart) + { + var cmd = row.Source.CommandDetails; + if (cmd?.QualifierAsduOffset is int offset && asduStart + offset < bytes.Length) + return bytes[asduStart + offset]; + return cmd?.QualifierRaw is byte raw ? $"0x{raw:X2}" : "No qualifier byte"; + } + private static string BuildControlEvidence(EvidenceRow row, string rawControl) { var flags = row.LinkFlags == "-" ? string.Empty : " · " + row.LinkFlags; @@ -343,6 +362,10 @@ private static string BuildControlMeaning(EvidenceRow row) if (row.Direction == "RX") { + if (row.Source.Frame?.LinkControl is { Prm: false, FunctionCode: 9 }) + return "Secondary FC=9: NO REQUESTED DATA; not an application command confirmation."; + if (row.Source.Frame?.LinkControl is { Prm: false, FunctionCode: 0 }) + return "Secondary FC=0: link-layer ACK, not proof that the physical command operated."; if (row.Acd == "1") { return "Relay response indicates ACD=1, meaning Class 1 data is pending and the master may drain event data."; @@ -368,7 +391,8 @@ private static string BuildAsduHeaderMeaning(EvidenceRow row) return "No IEC-10x ASDU payload is present in this frame."; } - return $"Type ID={row.TypeIdName}, VSQ={row.Vsq}, COT={row.CotDisplay}, CA={row.CommonAddress}. These fields define the telecontrol data class, cause, station/common address, and object addressing context."; + var detail = row.Source.CauseDetails?.Detail ?? $"COT={row.CotDisplay}"; + return $"Type ID={row.TypeIdName}, VSQ={row.Vsq}, {detail} CA={row.CommonAddress}."; } if (row.AsduType == "-" && row.Cot == "-") @@ -404,6 +428,8 @@ private static string BuildPayloadMeaning(EvidenceRow row) var time = string.IsNullOrWhiteSpace(row.RelayTime) || row.RelayTime == "-" ? "No field timestamp decoded." : $"Field timestamp: {row.RelayTime}."; var typeText = row.ProtocolMode is "101" or "104" ? row.TypeIdName : row.AsduType; + if (row.Source.CommandDetails is { } command) return command.Detail; + if (typeText.Contains("Measur", StringComparison.OrdinalIgnoreCase)) { return $"Measurement payload. Decoded value/state: {state}. Quality: {row.Quality}. {time}"; @@ -423,10 +449,18 @@ private static string BuildPayloadMeaning(EvidenceRow row) private static string[] SplitHexBytes(string rawHex) { - return rawHex - .Split(new[] { ' ', '|', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .Where(x => !string.IsNullOrWhiteSpace(x) && x != "-") - .ToArray(); + // Master uses spaced HEX; Passive Monitor/archives may use compact HEX. + // This executes only upon selection, not in the receive loop. + var tokens = rawHex.Split(new[] { ' ', '|', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + var bytes = new List(tokens.Length); + foreach (var token in tokens) + { + if (token == "-") continue; + if (token.Length > 2 && (token.Length & 1) == 0 && token.All(Uri.IsHexDigit)) + for (var i = 0; i < token.Length; i += 2) bytes.Add(token.Substring(i, 2)); + else bytes.Add(token); + } + return bytes.ToArray(); } private static IEnumerable BuildHexSegments(EvidenceRow row) From a9cc199c5740a5d5b0af25ac8db60fc0d900fda7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:50:54 +0700 Subject: [PATCH 09/23] fix(iec101): count secondary FC9 directly instead of NO DATA text (#39) --- .../Features/MainWindow.LiveEvidencePipeline.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs b/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs index 82b93e0..ba4add9 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs @@ -1185,7 +1185,10 @@ private void UpdateLiveCounters(Iec103MasterEvidenceEvent item) } } - if (item.Summary.Contains("NO DATA", StringComparison.OrdinalIgnoreCase) || item.Detail.Contains("NO DATA", StringComparison.OrdinalIgnoreCase)) + // Use numeric IEC-101 secondary FC9, not message wording, for the NO DATA counter. + if (item.ProtocolMode == Iec60870ProtocolMode.Iec101 + ? item.Frame?.LinkControl is { Prm: false, FunctionCode: 9 } + : item.Summary.Contains("NO DATA", StringComparison.OrdinalIgnoreCase) || item.Detail.Contains("NO DATA", StringComparison.OrdinalIgnoreCase)) { _noDataCount++; } From cff684fccf0a362dead23d91c8ed913eeae1ee91 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:51:27 +0700 Subject: [PATCH 10/23] test(iec10x): synthetic command ACTCON/negative, COT flags, quality, exact offsets (#39) --- .../Iec10xCommandForensicsTests.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs diff --git a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs new file mode 100644 index 0000000..3b7a05c --- /dev/null +++ b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs @@ -0,0 +1,140 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using ARIEC60870.Master.Model; +using ARIEC60870.Master.Protocol.Iec10x; +using Xunit; + +namespace ARIEC60870.Master.Tests; + +/// Public synthetic vectors only: no customer/field-capture bytes. +public sealed class Iec10xCommandForensicsTests +{ + private readonly Iec10xAsduDecoder _decoder = new(cotSize: 2, caSize: 2, ioaSize: 3); + + [Fact] + public void SelectAndRtuPositiveConfirmationPreserveSCOAndExactQualifierOffset() + { + var command = Iec10xAsduBuilder.SingleCommand(Settings(), ioa: 77, value: true, select: true, qualifier: 3); + var sent = _decoder.Decode(command); + + Assert.NotNull(sent.CommandDetails); + Assert.Equal(6, sent.CauseDetails!.Code); + Assert.Equal(0, sent.CauseDetails.Raw & 0xC0); + Assert.True(sent.CommandDetails!.Select); + Assert.Equal("SELECT", sent.CommandDetails.Operation); + Assert.Equal(3, sent.CommandDetails.QualifierValue); + Assert.Equal(9, sent.CommandDetails.QualifierAsduOffset); + Assert.Equal(9, sent.Objects[0].InformationElementOffset); + Assert.Equal(6, sent.Objects[0].InformationObjectOffset); + Assert.Equal(command[9], sent.CommandDetails.QualifierRaw); + + var positiveReply = (byte[])command.Clone(); + positiveReply[2] = 0x07; // COT 7 ACTCON + var rt = _decoder.Decode(positiveReply); + + Assert.Equal(7, rt.CauseDetails!.Code); + Assert.False(rt.CauseDetails.Negative); + Assert.Contains("Activation confirmed", rt.CauseDetails.Outcome); + Assert.Contains("physical operation NOT proven", rt.CauseDetails.Outcome); + Assert.Equal(sent.CommandDetails.QualifierRaw, rt.CommandDetails!.QualifierRaw); + } + + [Fact] + public void NegativeCommandAnswerIsDistinctFromSuccessfulActivation() + { + var command = Iec10xAsduBuilder.DoubleCommand(Settings(), ioa: 123, dcs: 2, select: false, qualifier: 5); + command[2] = 0x47; // COT7 with P/N=1 (negative confirmation) + command[3] = 0x04; // OA=4 + var decoded = _decoder.Decode(command); + + Assert.True(decoded.CauseDetails!.Negative); + Assert.False(decoded.CauseDetails.Test); + Assert.Equal(4, decoded.CauseDetails.OriginatorAddress); + Assert.Equal(7, decoded.CauseDetails.Code); + Assert.Contains("NEGATIVE", decoded.CauseDetails.Outcome); + Assert.Equal("EXECUTE", decoded.CommandDetails!.Operation); + Assert.Equal(5, decoded.CommandDetails.QualifierValue); + Assert.Contains("DCS=2", decoded.CommandDetails.ValueMeaning); + } + + [Fact] + public void TestFlagAndUnknownInformationObjectAddressRemainVisible() + { + var command = Iec10xAsduBuilder.SingleCommand(Settings(), ioa: 42, value: false, select: false); + command[2] = 0xAF; // COT=47, P/N=0, TEST=1 + var decoded = _decoder.Decode(command); + + Assert.Equal(47, decoded.CauseDetails!.Code); + Assert.True(decoded.CauseDetails.Test); + Assert.False(decoded.CauseDetails.Negative); + Assert.Contains("Unknown information object address", decoded.CauseDetails.Phase); + Assert.Contains("Protocol error", decoded.CauseDetails.Outcome); + } + + [Fact] + public void ActivationTerminationIsSeparateFromActconAndProcessFeedback() + { + var cmd = Iec10xAsduBuilder.GeneralInterrogation(Settings()); + var decoded = _decoder.Decode(cmd); + Assert.Equal(20, decoded.CommandDetails!.QualifierRaw); + Assert.Equal("Station GI", decoded.CommandDetails.ValueMeaning); + + cmd[2] = 10; + var terminated = _decoder.Decode(cmd); + Assert.Equal("Activation termination", terminated.CauseDetails!.Phase); + Assert.Contains("verify returned process state separately", terminated.CauseDetails.Outcome); + } + + [Fact] + public void DiqCannotRaiseOverflowBadgeButQdsCan() + { + var diq = Iec10xQualityDetails.Decode(31, new byte[] { 0x01 }); + Assert.NotNull(diq); + Assert.Null(diq!.Overflow); + Assert.Equal("Good", diq.DisplayText); + Assert.Contains("OV=N/A", diq.BitDetails); + + var qds = Iec10xQualityDetails.Decode(13, new byte[] { 0, 0, 0, 0, 0x81 }); + Assert.NotNull(qds); + Assert.True(qds!.Overflow); + Assert.True(qds.Invalid); + Assert.Equal(new[] { "INVALID", "OVF" }, qds.ActiveBadges); + } + + [Fact] + public void CounterCarryIsNotMeasurementOverflow() + { + var bcr = Iec10xQualityDetails.Decode(15, new byte[] { 1, 2, 3, 4, 0xA5 }); + Assert.NotNull(bcr); + Assert.Null(bcr!.Overflow); + Assert.True(bcr.Carry); + Assert.Equal(5, bcr.CounterSequence); + Assert.Contains("CARRY", bcr.ActiveBadges); + Assert.DoesNotContain("OVF", bcr.ActiveBadges); + } + + [Fact] + public void SqSequenceObjectsRetainIndependentAsduOffsets() + { + byte[] asdu = + { + 0x01, 0x82, 0x14, 0x00, 0x01, 0x00, // Type 1, 2 objects, COT20, CA1 + 0x64, 0x00, 0x00, 0x01, 0x00 // IOA100 ON, IOA101 OFF + }; + var d = _decoder.Decode(asdu); + Assert.Equal(2, d.Objects.Count); + Assert.Equal(9, d.Objects[0].InformationElementOffset); + Assert.Equal(10, d.Objects[1].InformationElementOffset); + Assert.Equal(-1, d.Objects[1].InformationObjectOffset); // SQ1: no additional IOA on wire + } + + private static Iec103MasterSettings Settings() => new() + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + CauseOfTransmissionSize = 2, + CommonAddressSize = 2, + InformationObjectAddressSize = 3, + CommonAddress = 1 + }; +} From 21153655c411419062fcb43eeed7acedc2b0ffc2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:51:58 +0700 Subject: [PATCH 11/23] test(iec10x): use explicit nullable comparisons for qualifier/quality regression asserts (#39) --- .../Iec10xCommandForensicsTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs index 3b7a05c..e28bf7a 100644 --- a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs +++ b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs @@ -21,13 +21,13 @@ public void SelectAndRtuPositiveConfirmationPreserveSCOAndExactQualifierOffset() Assert.NotNull(sent.CommandDetails); Assert.Equal(6, sent.CauseDetails!.Code); Assert.Equal(0, sent.CauseDetails.Raw & 0xC0); - Assert.True(sent.CommandDetails!.Select); + Assert.True(sent.CommandDetails!.Select == true); Assert.Equal("SELECT", sent.CommandDetails.Operation); Assert.Equal(3, sent.CommandDetails.QualifierValue); Assert.Equal(9, sent.CommandDetails.QualifierAsduOffset); Assert.Equal(9, sent.Objects[0].InformationElementOffset); Assert.Equal(6, sent.Objects[0].InformationObjectOffset); - Assert.Equal(command[9], sent.CommandDetails.QualifierRaw); + Assert.Equal((byte?)command[9], sent.CommandDetails.QualifierRaw); var positiveReply = (byte[])command.Clone(); positiveReply[2] = 0x07; // COT 7 ACTCON @@ -77,7 +77,7 @@ public void ActivationTerminationIsSeparateFromActconAndProcessFeedback() { var cmd = Iec10xAsduBuilder.GeneralInterrogation(Settings()); var decoded = _decoder.Decode(cmd); - Assert.Equal(20, decoded.CommandDetails!.QualifierRaw); + Assert.Equal((byte?)20, decoded.CommandDetails!.QualifierRaw); Assert.Equal("Station GI", decoded.CommandDetails.ValueMeaning); cmd[2] = 10; @@ -97,7 +97,7 @@ public void DiqCannotRaiseOverflowBadgeButQdsCan() var qds = Iec10xQualityDetails.Decode(13, new byte[] { 0, 0, 0, 0, 0x81 }); Assert.NotNull(qds); - Assert.True(qds!.Overflow); + Assert.True(qds!.Overflow == true); Assert.True(qds.Invalid); Assert.Equal(new[] { "INVALID", "OVF" }, qds.ActiveBadges); } @@ -108,7 +108,7 @@ public void CounterCarryIsNotMeasurementOverflow() var bcr = Iec10xQualityDetails.Decode(15, new byte[] { 1, 2, 3, 4, 0xA5 }); Assert.NotNull(bcr); Assert.Null(bcr!.Overflow); - Assert.True(bcr.Carry); + Assert.True(bcr.Carry == true); Assert.Equal(5, bcr.CounterSequence); Assert.Contains("CARRY", bcr.ActiveBadges); Assert.DoesNotContain("OVF", bcr.ActiveBadges); From 42f7c5c9580fee2fcd4a3a0152d9763417a91de1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:52:29 +0700 Subject: [PATCH 12/23] docs: record M1 command evidence contract, limitations and next gates (#39) --- docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md diff --git a/docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md b/docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md new file mode 100644 index 0000000..9b59c91 --- /dev/null +++ b/docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md @@ -0,0 +1,37 @@ +# M1: IEC-101/104 Command and Response Forensic Semantics + +Related: [Issue #39](https://github.com/masarray/ARIEC60870/issues/39) + +## Field requirement +Command actions and the RTU's answer must be decipherable with the same rigor as a spontaneous event: COT numeric/flags, raw qualifier and its bits, Type, CA, IOA, current link/APCI context, source bytes and exact ASDU information-element offsets. Never report successful switchgear motion merely because a link ACK or COT7 ACTCON was observed. + +## M1 implementation (this branch) +- Add immutable semantic facts `Iec10xCauseDetails`, `Iec10xQualityDetails`, and `Iec10xCommandDetails` owned by the IEC-10x decoder layer (not re-parsed from UI strings). +- Preserve numeric raw COT and flags P/N, TEST, OA (including zero when second COT octet configured). Explicit phase/outcome descriptions for COT5/6/7/8/9/10/11/12/44..47. +- Decode per-information-object quality (SIQ, DIQ, QDS and BCR); overflow only exists for QDS. BCR carry is not overflow. All inactive quality bits and N/A remain available in structured properties. +- Preserve ASDU-relative information-object and element offsets. For SQ1 subsequent objects, the implicit IOA has no physical on-wire IOA offset (represented by -1). +- Type-aware command qualifier interpretation: single/double/regulating, select vs execute, QOS/QU, QOI, QCC, QRP, read/clock/test baseline. Decoder never infers successful primary-process actuation from protocol confirmation. +- Carry command/COT/quality information as typed evidence through IEC-101 Active Master, IEC-104 client, and IEC-101 Passive Monitor, and include all details in selected Frame Inspector. +- Fix compact HEX from Passive Monitor being treated as a single byte in Frame Inspector; fix IEC-101 FC9 NO DATA count by numeric function code. + +## M1 deliberate boundaries +This is the **first implementation milestone**, not the finished unified forensic system. + +Not yet implemented here: +- Multi-stage command transaction correlation (SBO SELECT → ACTCON → EXECUTE → ACTCON → ACTTERM and optional returned state), with ambiguity, missing/timeout and RTU processing timing caveats. Existing passive request/reply correlator remains in place. +- Structured full byte-range map and interactive bit overlays for every field, especially 0/2-byte link addressing; M1 exposes exact ASDU qualifier element offsets but the old UI grouping still has known fixed-offset limitations. +- Compact premium badge templates in the single Quality column on Values/Events; typed flags now exist as the source for that follow-up. **Do not add per-flag columns.** +- Event Log coverage correction for all first-observed/repeated spontaneous event objects; complete per-object provenance and new capture schema. +- Protocol-specific IEC-103 command data model; current milestone is IEC-101/104 shared ASDU. +- UI thread/performance baselines, Windows manual GUI, full hardware SICAM interoperability. Require CI and field verification later. + +## Next milestones +M2: immutable canonical Frame/ASDU/Object/Transaction IDs, Active Master + Passive + Replay parity, event provenance and action lifecycle. +M3: one Quality badge cell, one COT cell, linked full-bit/field inspector + source-highlight, report/.ariec parity. +M4: Windows GUI smoke, sustained replay/CPU/memory benchmarks and reproducible hardware test build. + +## Public synthetic tests (no private captures) +Test C_SC SELECT, ACTCON positive, C_DC EXECUTE negative P/N, TEST and COT47 error, ACTTERM and QOI, DIQ overflow N/A, QDS OVF+IV, BCR carry, SQ1 exact byte offsets. Tests are in `tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs`. + +## Engineering guardrails +Do not alter scheduling/FCB state-machine behavior in M1, and never add TX code to Passive Monitor. Parser work must be pure, bounded and independent of WPF. Never infer actual switchgear movement without later process-state evidence. Raw capture evidence is private and must not be committed to public fixtures. From 5691f582cffc67b919e50052e0d3925eeb496d22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:53:34 +0700 Subject: [PATCH 13/23] feat(iec10x): include bitstring command Type51/64 and time-tagged response evidence (#39) --- src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs index 43459d3..974678f 100644 --- a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs @@ -211,9 +211,11 @@ private static string BuildElementSummary(string value, string quality, string t 45 or 46 or 47 => 1, 48 or 49 => 3, 50 => 5, + 51 => 4, 58 or 59 or 60 => 8, 61 or 62 => 10, 63 => 12, + 64 => 11, 100 or 101 or 105 => 1, 102 => 0, 103 => 7, @@ -253,6 +255,7 @@ private static string DecodeValueText(byte type, byte[] data, List issue 48 or 61 => data.Length >= 2 ? $"Setpoint normalized={ReadNormalized(data).ToString("0.#####", CultureInfo.InvariantCulture)}" : string.Empty, 49 or 62 => data.Length >= 2 ? $"Setpoint scaled={BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0, 2))}" : string.Empty, 50 or 63 => data.Length >= 4 ? $"Setpoint float={BinaryPrimitives.ReadSingleLittleEndian(data.AsSpan(0, 4)).ToString("0.###", CultureInfo.InvariantCulture)}" : string.Empty, + 51 or 64 => data.Length >= 4 ? $"Bitstring command=0x{BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0, 4)):X8}" : string.Empty, 100 => data.Length >= 1 ? $"Interrogation qualifier QOI={data[0]}" : string.Empty, 101 => data.Length >= 1 ? $"Counter interrogation qualifier QCC=0x{data[0]:X2}" : string.Empty, 102 => "Read command", @@ -309,6 +312,7 @@ private static string DecodeTimestampText(byte type, byte[] data) 58 or 59 or 60 => data.Length >= 8 ? DecodeCp56Time2a(data.AsSpan(1, 7)) : string.Empty, 61 or 62 => data.Length >= 10 ? DecodeCp56Time2a(data.AsSpan(3, 7)) : string.Empty, 63 => data.Length >= 12 ? DecodeCp56Time2a(data.AsSpan(5, 7)) : string.Empty, + 64 => data.Length >= 11 ? DecodeCp56Time2a(data.AsSpan(4, 7)) : string.Empty, 103 => data.Length >= 7 ? DecodeCp56Time2a(data.AsSpan(0, 7)) : string.Empty, _ => string.Empty }; @@ -448,12 +452,14 @@ public static string DecodeCp56Time2a(ReadOnlySpan data) 48 => "C_SE_NA_1 setpoint normalized", 49 => "C_SE_NB_1 setpoint scaled", 50 => "C_SE_NC_1 setpoint short float", + 51 => "C_BO_NA_1 bitstring command", 58 => "C_SC_TA_1 single command with CP56Time2a", 59 => "C_DC_TA_1 double command with CP56Time2a", 60 => "C_RC_TA_1 regulating step with CP56Time2a", 61 => "C_SE_TA_1 setpoint normalized with CP56Time2a", 62 => "C_SE_TB_1 setpoint scaled with CP56Time2a", 63 => "C_SE_TC_1 setpoint short float", + 64 => "C_BO_TA_1 bitstring command with CP56Time2a", 100 => "C_IC_NA_1 interrogation command", 101 => "C_CI_NA_1 counter interrogation", 102 => "C_RD_NA_1 read command", From ef7f766edb53478a6fba9340ab34bc3e5b172f4c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:53:37 +0700 Subject: [PATCH 14/23] feat(iec10x): include bitstring command Type51/64 and time-tagged response evidence (#39) --- src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs index aab3d5f..25abe4f 100644 --- a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs @@ -133,6 +133,7 @@ public sealed record Iec10xCommandDetails( 48 or 61 => "C_SE normalized setpoint (QOS)", 49 or 62 => "C_SE scaled setpoint (QOS)", 50 or 63 => "C_SE float setpoint (QOS)", + 51 or 64 => "C_BO 32-bit bitstring command" 100 => "C_IC general interrogation (QOI)", 101 => "C_CI counter interrogation (QCC)", 102 => "C_RD read command", From 167646371a4762c889b7eddcce0707741bd3a9af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:54:00 +0700 Subject: [PATCH 15/23] refactor(iec10x): remove superseded duplicate textual quality decoder (#39) --- .../Protocol/Iec10x/Iec10xAsduDecoder.cs | 51 ------------------- 1 file changed, 51 deletions(-) diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs index 974678f..0055082 100644 --- a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs @@ -270,31 +270,6 @@ private static string DecodeValueText(byte type, byte[] data, List issue } } - private static string DecodeQualityText(byte type, byte[] data) - { - if (data.Length == 0) - { - return string.Empty; - } - - return type switch - { - 1 or 2 or 30 => DecodeSiq(data[0]), - 3 or 4 or 31 => DecodeDiq(data[0]), - 5 or 32 => data.Length >= 2 ? DecodeQds(data[1]) : string.Empty, - 6 => data.Length >= 2 ? DecodeQds(data[1]) : string.Empty, - 7 or 33 => data.Length >= 5 ? DecodeQds(data[4]) : string.Empty, - 8 => data.Length >= 5 ? DecodeQds(data[4]) : string.Empty, - 9 or 11 or 34 or 35 => data.Length >= 3 ? DecodeQds(data[2]) : string.Empty, - 10 or 12 => data.Length >= 3 ? DecodeQds(data[2]) : string.Empty, - 13 or 36 => data.Length >= 5 ? DecodeQds(data[4]) : string.Empty, - 14 => data.Length >= 5 ? DecodeQds(data[4]) : string.Empty, - 15 or 37 => data.Length >= 5 ? DecodeBcrQuality(data[4]) : string.Empty, - 16 => data.Length >= 5 ? DecodeBcrQuality(data[4]) : string.Empty, - _ => string.Empty - }; - } - private static string DecodeTimestampText(byte type, byte[] data) { return type switch @@ -345,23 +320,6 @@ private static string DecodeDoubleCommand(byte dco) return $"Double command={state}, {select}, QU={qualifier}"; } - private static string DecodeSiq(byte q) => DecodeQualityFlags(q, hasOverflow: false); - - private static string DecodeDiq(byte q) => DecodeQualityFlags(q, hasOverflow: false); - - private static string DecodeQds(byte q) => DecodeQualityFlags(q, hasOverflow: true); - - private static string DecodeQualityFlags(byte q, bool hasOverflow) - { - var flags = new List(); - if (hasOverflow && (q & 0x01) != 0) flags.Add("Overflow"); - if ((q & 0x10) != 0) flags.Add("Blocked"); - if ((q & 0x20) != 0) flags.Add("Substituted"); - if ((q & 0x40) != 0) flags.Add("Not topical"); - if ((q & 0x80) != 0) flags.Add("Invalid"); - return flags.Count == 0 ? "Good" : string.Join(", ", flags); - } - private static string DecodeBcrValue(byte[] data) { if (data.Length < 5) @@ -375,15 +333,6 @@ private static string DecodeBcrValue(byte[] data) return $"Counter={counter}, sequence={sequence}"; } - private static string DecodeBcrQuality(byte q) - { - var flags = new List(); - if ((q & 0x20) != 0) flags.Add("Carry"); - if ((q & 0x40) != 0) flags.Add("Adjusted"); - if ((q & 0x80) != 0) flags.Add("Invalid"); - return flags.Count == 0 ? "Good" : string.Join(", ", flags); - } - private static double ReadNormalized(byte[] data) { var raw = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0, 2)); From 1fc06deb74866c7078bc2c18fbd3d257c1875527 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:55:35 +0700 Subject: [PATCH 16/23] fix(iec10x): comma in C_BO command semantic switch discovered by Windows CI (#39) --- src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs index 25abe4f..9172880 100644 --- a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xSemanticDetails.cs @@ -133,7 +133,7 @@ public sealed record Iec10xCommandDetails( 48 or 61 => "C_SE normalized setpoint (QOS)", 49 or 62 => "C_SE scaled setpoint (QOS)", 50 or 63 => "C_SE float setpoint (QOS)", - 51 or 64 => "C_BO 32-bit bitstring command" + 51 or 64 => "C_BO 32-bit bitstring command", 100 => "C_IC general interrogation (QOI)", 101 => "C_CI counter interrogation (QCC)", 102 => "C_RD read command", From 9d4abacd19f6234dbd0f6b9b9d3ea08f30796845 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:56:24 +0700 Subject: [PATCH 17/23] fix(iec10x): do not label command ASDUs without QDS/DIQ as Quality=Good (#39) --- src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs index 15354e3..663e273 100644 --- a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecode.cs @@ -76,7 +76,8 @@ public string ReadableSummary { get { - var quality = string.IsNullOrWhiteSpace(QualityText) ? "Quality=Good" : $"Quality={QualityText}"; + // A command without a quality field is NOT known-good process data. + var quality = QualityDetails is null ? "Quality=N/A (not present)" : $"Quality={QualityDetails.DisplayText}"; var time = string.IsNullOrWhiteSpace(TimestampText) ? string.Empty : $", Time={TimestampText}"; return $"IOA={InformationObjectAddress}, Value={ShortValue}, {quality}{time}"; } From e6e56c9643509fdbd6798ec4ad21b0103d9322d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:56:35 +0700 Subject: [PATCH 18/23] test(iec10x): commands without quality octet must not display Good (#39) --- tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs index e28bf7a..1032d1c 100644 --- a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs +++ b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs @@ -26,6 +26,8 @@ public void SelectAndRtuPositiveConfirmationPreserveSCOAndExactQualifierOffset() Assert.Equal(3, sent.CommandDetails.QualifierValue); Assert.Equal(9, sent.CommandDetails.QualifierAsduOffset); Assert.Equal(9, sent.Objects[0].InformationElementOffset); + Assert.Null(sent.Objects[0].QualityDetails); + Assert.Contains("Quality=N/A", sent.Objects[0].ReadableSummary); Assert.Equal(6, sent.Objects[0].InformationObjectOffset); Assert.Equal((byte?)command[9], sent.CommandDetails.QualifierRaw); From 6edbecafadeca8fc9d11a44afab65553a18c23a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:57:07 +0700 Subject: [PATCH 19/23] fix(trace): use IEC101 link width 0/1/2 for frame control, ASDU and checksum field mapping (#39) --- .../Features/MainWindow.FrameInspector.cs | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs index 65cb764..ef2c83e 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs @@ -119,6 +119,7 @@ private static string DescribeProtocolMapKey(string key) "ft12" => "FT1.2 selected", "control" => "link control selected", "asdu" => "ASDU header selected", + "cot" => "COT and flags selected", "object" => "object address selected", "payload" => "payload selected", "value" => "value selected", @@ -282,8 +283,12 @@ private static IEnumerable BuildProtocolMapLines(EvidenceRow ro if (row.ProtocolMode == "101" && bytes[0].Equals("68", StringComparison.OrdinalIgnoreCase) && bytes.Length >= 9) { yield return new ProtocolMapLine("envelope", "FT1.2 variable frame", "IEC-101 serial variable-length frame. The repeated length block and checksum protect the serial telegram.", string.Join(" ", bytes.Take(4))); - yield return new ProtocolMapLine("control", "Link control", BuildControlMeaning(row), BuildControlEvidence(row, string.Join(" ", bytes.Skip(4).Take(2)))); - yield return new ProtocolMapLine("asdu", "ASDU header", BuildAsduHeaderMeaning(row), string.Join(" ", bytes.Skip(6).Take(Math.Min(6, Math.Max(0, bytes.Length - 8))))); + var linkSize = Math.Clamp(row.Source.Frame?.LinkAddressSize ?? 1, 0, 2); + var asduStart = 5 + linkSize; + yield return new ProtocolMapLine("control", "Link control and address", BuildControlMeaning(row), BuildControlEvidence(row, string.Join(" ", bytes.Skip(4).Take(1 + linkSize)))); + yield return new ProtocolMapLine("asdu", "ASDU header", BuildAsduHeaderMeaning(row), string.Join(" ", bytes.Skip(asduStart).Take(Math.Min(6, Math.Max(0, bytes.Length - asduStart - 2))))); + if (row.Source.CauseDetails is { } cause) + yield return new ProtocolMapLine("cot", "COT flags", cause.Detail, string.Join(" ", bytes.Skip(asduStart + 2).Take(cause.OriginatorAddress.HasValue ? 2 : 1))); yield return new ProtocolMapLine("object", "Information object address", BuildSignalAddressMeaning(row), row.ProtocolAddress); if (row.Source.CommandDetails is { } cmd101) yield return new ProtocolMapLine("command", "Command / RTU response", cmd101.Detail, CommandEvidence(row, bytes, 5 + (row.Source.Frame?.LinkAddressSize ?? 1))); @@ -305,9 +310,10 @@ private static IEnumerable BuildProtocolMapLines(EvidenceRow ro if (bytes[0].Equals("10", StringComparison.OrdinalIgnoreCase) && bytes.Length >= 5) { yield return new ProtocolMapLine("envelope", "FT1.2 fixed frame", $"Short {row.ProtocolName} link frame. Used for reset, Class 1/Class 2 request, ACK, or NO DATA response.", string.Join(" ", bytes.Take(1))); + var linkSize = Math.Clamp(row.Source.Frame?.LinkAddressSize ?? 1, 0, 2); yield return new ProtocolMapLine("control", "Control field", BuildControlMeaning(row), BuildControlEvidence(row, bytes.ElementAtOrDefault(1) ?? "-")); - yield return new ProtocolMapLine("address", "Link address", "Slave/link address on the serial IEC-60870 link.", bytes.ElementAtOrDefault(2) ?? "-"); - yield return new ProtocolMapLine("check", "Integrity", "Checksum and stop byte. This proves what was actually transmitted on the wire.", string.Join(" ", bytes.Skip(3).Take(2))); + yield return new ProtocolMapLine("address", "Link address", "Slave/link address on the serial IEC-60870 link.", linkSize == 0 ? "N/A (0 byte)" : string.Join(" ", bytes.Skip(2).Take(linkSize))); + yield return new ProtocolMapLine("check", "Integrity", "Checksum and stop byte. This proves what was actually transmitted on the wire.", string.Join(" ", bytes.Skip(2 + linkSize).Take(2))); yield break; } @@ -494,8 +500,10 @@ private static IEnumerable BuildHexSegments(EvidenceRow row) if (row.ProtocolMode == "101" && bytes[0].Equals("68", StringComparison.OrdinalIgnoreCase) && bytes.Length >= 9) { yield return new HexSegment("envelope", string.Join(" ", bytes.Take(4)), "FT1.2 variable frame", "IEC-101 variable-length serial frame envelope."); - yield return new HexSegment("control", string.Join(" ", bytes.Skip(4).Take(2)), "Control + link", BuildControlMeaning(row)); - yield return new HexSegment("asdu", string.Join(" ", bytes.Skip(6).Take(Math.Min(6, Math.Max(0, bytes.Length - 8)))), "ASDU header", BuildAsduHeaderMeaning(row)); + var linkSize = Math.Clamp(row.Source.Frame?.LinkAddressSize ?? 1, 0, 2); + var asduStart = 5 + linkSize; + yield return new HexSegment("control", string.Join(" ", bytes.Skip(4).Take(1 + linkSize)), "Control + link", BuildControlMeaning(row)); + yield return new HexSegment("asdu", string.Join(" ", bytes.Skip(asduStart).Take(Math.Min(6, Math.Max(0, bytes.Length - asduStart - 2)))), "ASDU header", BuildAsduHeaderMeaning(row)); yield return new HexSegment("object", row.ProtocolAddress, "Information object address", BuildSignalAddressMeaning(row)); if (!string.IsNullOrWhiteSpace(row.SemanticState)) { @@ -509,8 +517,9 @@ private static IEnumerable BuildHexSegments(EvidenceRow row) { yield return new HexSegment("envelope", bytes[0], "FT1.2 fixed frame", "Fixed-length link frame envelope."); yield return new HexSegment("control", bytes[1], "Control", BuildControlMeaning(row)); - yield return new HexSegment("address", bytes[2], "Link address", "Relay/slave link address."); - yield return new HexSegment("check", string.Join(" ", bytes.Skip(3).Take(2)), "Integrity", "Checksum and end byte."); + var linkSize = Math.Clamp(row.Source.Frame?.LinkAddressSize ?? 1, 0, 2); + yield return new HexSegment("address", linkSize == 0 ? "N/A" : string.Join(" ", bytes.Skip(2).Take(linkSize)), "Link address", "Relay/slave link address."); + yield return new HexSegment("check", string.Join(" ", bytes.Skip(2 + linkSize).Take(2)), "Integrity", "Checksum and end byte."); yield break; } From b176a45cb11366a0af9ac15c5f81977349894f4a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:57:32 +0700 Subject: [PATCH 20/23] test(iec10x): cover QOS select and C_BO time-tagged command response (#39) --- .../Iec10xCommandForensicsTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs index 1032d1c..fc1fef9 100644 --- a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs +++ b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs @@ -131,6 +131,46 @@ public void SqSequenceObjectsRetainIndependentAsduOffsets() Assert.Equal(-1, d.Objects[1].InformationObjectOffset); // SQ1: no additional IOA on wire } + [Fact] + public void SetpointSelectQualifierAndBitstringCommandRemainDecoded() + { + var setpoint = Iec10xAsduBuilder.SetpointNormalizedCommand(Settings(), ioa: 100, normalizedValue: 0.25, select: true, qualifier: 9); + var decodedSetpoint = _decoder.Decode(setpoint); + Assert.Equal("SELECT", decodedSetpoint.CommandDetails!.Operation); + Assert.Equal(9, decodedSetpoint.CommandDetails.QualifierValue); + Assert.Equal(11, decodedSetpoint.CommandDetails.QualifierAsduOffset); + Assert.Equal(setpoint[11], decodedSetpoint.CommandDetails.QualifierRaw); + + byte[] bitstring = + { + 51, 1, 6, 0, 1, 0, 123, 0, 0, + 0x78, 0x56, 0x34, 0x12 + }; + var decodedBits = _decoder.Decode(bitstring); + Assert.Empty(decodedBits.Issues); + Assert.NotNull(decodedBits.CommandDetails); + Assert.Null(decodedBits.CommandDetails!.QualifierRaw); + Assert.Contains("0x12345678", decodedBits.CommandDetails.ValueMeaning); + Assert.Null(decodedBits.Objects[0].QualityDetails); + } + + [Fact] + public void TimeTaggedBitstringCommandCarriesCp56WithoutFalseQuality() + { + byte[] asdu = + { + 64, 1, 7, 0, 1, 0, 5, 0, 0, + 0x78, 0x56, 0x34, 0x12, // 32-bit command + 0x7B, 0x00, 0x04, 0x0B, 0x1C, 0x0A, 0x1A // CP56Time2a + }; + var decoded = _decoder.Decode(asdu); + Assert.Empty(decoded.Issues); + Assert.NotNull(decoded.CommandDetails); + Assert.Equal(7, decoded.CauseDetails!.Code); + Assert.Contains("2026-10-28", decoded.Objects[0].TimestampText); + Assert.Contains("Quality=N/A", decoded.Objects[0].ReadableSummary); + } + private static Iec103MasterSettings Settings() => new() { ProtocolMode = Iec60870ProtocolMode.Iec101, From 5a1b1061d2f801e16f85bcd503a315f00b780f58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 14:57:45 +0700 Subject: [PATCH 21/23] test: compare nullable raw QOS bytes precisely (#39) --- tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs index fc1fef9..d4b8edb 100644 --- a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs +++ b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs @@ -139,7 +139,7 @@ public void SetpointSelectQualifierAndBitstringCommandRemainDecoded() Assert.Equal("SELECT", decodedSetpoint.CommandDetails!.Operation); Assert.Equal(9, decodedSetpoint.CommandDetails.QualifierValue); Assert.Equal(11, decodedSetpoint.CommandDetails.QualifierAsduOffset); - Assert.Equal(setpoint[11], decodedSetpoint.CommandDetails.QualifierRaw); + Assert.Equal((byte?)setpoint[11], decodedSetpoint.CommandDetails.QualifierRaw); byte[] bitstring = { From 41566466ccfdcaafaf360628748377ee4eb72ea8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 20:21:45 +0700 Subject: [PATCH 22/23] feat(iec10x): finish M1 typed per-object provenance across active dual-link and passive; correct CP56 DOW --- docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md | 8 ++++ .../Features/MainWindow.FrameInspector.cs | 19 +++++++- .../Features/MainWindow.PassiveMonitor.cs | 16 +++++-- .../Redundancy/Iec101DualLinkChannel.cs | 15 ++++++ .../Iec101DualLinkRedundancySession.cs | 5 ++ src/ARIEC60870.Master/Iec101MasterSession.cs | 11 +++++ src/ARIEC60870.Master/Iec104ClientSession.cs | 11 +++++ .../Model/Iec103MasterEvidenceEvent.cs | 3 ++ .../Protocol/Iec10x/Iec10xAsduDecoder.cs | 11 +++-- .../Iec10xCommandForensicsTests.cs | 47 +++++++++++++++++++ 10 files changed, 138 insertions(+), 8 deletions(-) diff --git a/docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md b/docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md index 9b59c91..6bd020d 100644 --- a/docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md +++ b/docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md @@ -35,3 +35,11 @@ Test C_SC SELECT, ACTCON positive, C_DC EXECUTE negative P/N, TEST and COT47 err ## Engineering guardrails Do not alter scheduling/FCB state-machine behavior in M1, and never add TX code to Passive Monitor. Parser work must be pure, bounded and independent of WPF. Never infer actual switchgear movement without later process-state evidence. Raw capture evidence is private and must not be committed to public fixtures. + +## M1 continuation — typed per-object source and timestamp correctness + +- Retain a shared decoded ASDU and selected information-object reference in every IEC-101/104 Active Master, IEC-101 redundant and IEC-101 Passive event. Reuse one source record rather than parsing from WPF strings or copying decoded raw bytes per object. +- Secondary objects preserve their own typed COT, command, quality and exact ASDU-relative object/element byte offsets, even for SQ1 implicit IOAs. The inspector shows the selected object's metadata and a bounded first-page overview of an ASDU's objects. +- IEC-101 E5 in the inspector is contextual (link ACK versus Class 1/2 no-data), never proof of successful application command. +- CP56Time2a day-of-week high bits must not be treated as invalid-time. Clock/calendar bounds and minute IV are checked. CP24 hourless clock ranges are checked. Public regression fixtures cover valid DOW and invalid ranges. +- Passive multi-object projection encodes HEX once per frame instead of once per IOA. Full command transaction pairing, completed premium badge templates and report/capture SSOT remain M2/M3; no field-device FAT is implied. diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs index ef2c83e..649be09 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs @@ -178,6 +178,21 @@ private static string BuildCompactFrameExplanation(EvidenceRow row) parts.Add("Command / response: " + cmd.Detail); if (row.Source.QualityDetails is { } quality) parts.Add("Quality bits: " + quality.BitDetails); + if (row.Source.InformationObjectDetails is { } selectedObject) + parts.Add($"Object #{selectedObject.Index} IOA={selectedObject.InformationObjectAddress}; " + + $"ASDU IOA offset={(selectedObject.InformationObjectOffset < 0 ? "implicit SQ" : selectedObject.InformationObjectOffset.ToString())}; " + + $"element byte offset={selectedObject.InformationElementOffset}; length={selectedObject.RawBytes.Count}."); + if (row.Source.AsduDetails is { Objects.Count: > 1 } parsed && + row.Source.InformationObjectDetails?.Index == 1) + { + // Lazy, bounded selected-frame inspection. The complete decoded object array + // stays available as one shared source, without adding UI grid columns. + foreach (var obj in parsed.Objects.Take(16)) + parts.Add($"#{obj.Index} IOA={obj.InformationObjectAddress}: {obj.ShortValue}; " + + $"quality={obj.QualityDetails?.DisplayText ?? "N/A"}; element byte={obj.InformationElementOffset}."); + if (parsed.Objects.Count > 16) + parts.Add($"+{parsed.Objects.Count - 16} more objects retained in the decoded ASDU."); + } parts.Add(row.ProtocolMode switch { @@ -302,7 +317,9 @@ private static IEnumerable BuildProtocolMapLines(EvidenceRow ro if (bytes[0].Equals("E5", StringComparison.OrdinalIgnoreCase)) { - yield return new ProtocolMapLine("envelope", "Single char ACK", "IEC FT1.2 single-character acknowledgement. The relay accepted the previous link/action frame.", "E5"); + yield return new ProtocolMapLine("envelope", "E5 short response", + "IEC-101 E5 has context-dependent meaning: link acknowledgement or NO DATA for a pending Class 1/2 request. " + + "Without an unambiguous matched request the application outcome is unknown; it does not prove command execution.", "E5"); yield break; } diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs index 2e8a451..e728ae4 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs @@ -644,6 +644,7 @@ private void FeedPassiveFrameToExistingViews(PassiveObservedFrame seen) if (!seen.ChecksumValid && seen.Format != "Partial") meaning = "Bad checksum · " + meaning; var first = decode?.FirstObject; + var rawHexOnce = Convert.ToHexString(raw); var ev = new Iec103MasterEvidenceEvent { @@ -659,7 +660,7 @@ private void FeedPassiveFrameToExistingViews(PassiveObservedFrame seen) (decode?.Issues.Count > 0 ? " · " + string.Join("; ", decode.Issues) : ""), OperatorMessage = meaning, ProtocolMeaning = meaning, OperatorAction = "Observed only; no commands transmitted", - RawHex = Convert.ToHexString(raw), + RawHex = rawHexOnce, Frame = frame, LinkAddress = control.HasValue ? address : null, TypeId = decode?.TypeId, TypeName = decode?.TypeName ?? "", @@ -669,6 +670,8 @@ private void FeedPassiveFrameToExistingViews(PassiveObservedFrame seen) CauseOfTransmission = decode?.CauseOfTransmission, CauseDetails = decode?.CauseDetails, CommandDetails = decode?.CommandDetails, + AsduDetails = decode, + InformationObjectDetails = first, QualityDetails = first?.QualityDetails, CauseName = decode?.CauseName ?? "", OriginatorAddress = decode?.OriginatorAddress, @@ -706,9 +709,15 @@ private void FeedPassiveFrameToExistingViews(PassiveObservedFrame seen) ProtocolMode = Iec60870ProtocolMode.Iec101, Category = "Passive Point", PollingReason = "PASSIVE-POINT", - RawHex = Convert.ToHexString(raw), + RawHex = rawHexOnce, TypeId = decode.TypeId, TypeName = decode.TypeName, CauseOfTransmission = decode.CauseOfTransmission, + CauseDetails = decode.CauseDetails, + AsduDetails = decode, + InformationObjectDetails = obj, + QualityDetails = obj.QualityDetails, + CommandDetails = Iec10xCommandDetails.Decode(decode.TypeId, obj, decode.CauseDetails!), + QualityText = obj.QualityText, CauseName = decode.CauseName, CommonAddressNumber = decode.CommonAddress, InformationObjectAddress = obj.InformationObjectAddress, ObjectSummary = obj.ReadableSummary, @@ -720,7 +729,8 @@ private void FeedPassiveFrameToExistingViews(PassiveObservedFrame seen) SignalType = decode.TypeName, SignalDisplayValue = obj.ShortValue, SignalRawValue = obj.ValueText, - RelayTimestampText = obj.TimestampText + RelayTimestampText = obj.TimestampText, + RelayTimestampInvalid = obj.TimestampText.Contains("invalid", StringComparison.OrdinalIgnoreCase) }); } } diff --git a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs index 9ca1f2f..5152585 100644 --- a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs +++ b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs @@ -358,6 +358,11 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, IsSequenceAsdu = asdu?.IsSequence, ObjectCount = asdu?.ObjectCount, CauseOfTransmission = asdu?.CauseOfTransmission, + CauseDetails = asdu?.CauseDetails, + CommandDetails = asdu?.CommandDetails, + AsduDetails = asdu, + InformationObjectDetails = asdu?.FirstObject, + QualityDetails = asdu?.FirstObject?.QualityDetails, CauseName = asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = asdu?.OriginatorAddress, CommonAddressNumber = asdu?.CommonAddress, @@ -469,6 +474,11 @@ private async Task ReceiveOneAsync(string dataClass IsSequenceAsdu = asdu?.IsSequence, ObjectCount = asdu?.ObjectCount, CauseOfTransmission = asdu?.CauseOfTransmission, + CauseDetails = asdu?.CauseDetails, + CommandDetails = asdu?.CommandDetails, + AsduDetails = asdu, + InformationObjectDetails = asdu?.FirstObject, + QualityDetails = asdu?.FirstObject?.QualityDetails, CauseName = asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = asdu?.OriginatorAddress, CommonAddressNumber = asdu?.CommonAddress, @@ -590,6 +600,11 @@ private void PublishAdditionalObjectEvents(Ft12FrameDecode decoded, Iec10xAsduDe IsSequenceAsdu = asdu.IsSequence, ObjectCount = asdu.ObjectCount, CauseOfTransmission = asdu.CauseOfTransmission, + CauseDetails = asdu.CauseDetails, + CommandDetails = Iec10xCommandDetails.Decode(asdu.TypeId, obj, asdu.CauseDetails!), + AsduDetails = asdu, + InformationObjectDetails = obj, + QualityDetails = obj.QualityDetails, CauseName = asdu.CotNameWithFlags, OriginatorAddress = asdu.OriginatorAddress, CommonAddressNumber = asdu.CommonAddress, diff --git a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkRedundancySession.cs b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkRedundancySession.cs index 62b5401..2d4a020 100644 --- a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkRedundancySession.cs +++ b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkRedundancySession.cs @@ -1336,6 +1336,11 @@ private void AddEvent(Iec103MasterEvidenceEvent item) IsSequenceAsdu = item.IsSequenceAsdu, ObjectCount = item.ObjectCount, CauseOfTransmission = item.CauseOfTransmission, + CauseDetails = item.CauseDetails, + CommandDetails = item.CommandDetails, + QualityDetails = item.QualityDetails, + AsduDetails = item.AsduDetails, + InformationObjectDetails = item.InformationObjectDetails, CauseName = item.CauseName, OriginatorAddress = item.OriginatorAddress, CommonAddressNumber = item.CommonAddressNumber, diff --git a/src/ARIEC60870.Master/Iec101MasterSession.cs b/src/ARIEC60870.Master/Iec101MasterSession.cs index 89a8a92..576d55f 100644 --- a/src/ARIEC60870.Master/Iec101MasterSession.cs +++ b/src/ARIEC60870.Master/Iec101MasterSession.cs @@ -683,6 +683,8 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, CauseOfTransmission = asdu?.CauseOfTransmission, CauseDetails = asdu?.CauseDetails, CommandDetails = asdu?.CommandDetails, + AsduDetails = asdu, + InformationObjectDetails = asdu?.FirstObject, QualityDetails = asdu?.FirstObject?.QualityDetails, CauseName = asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = asdu?.OriginatorAddress, @@ -810,6 +812,8 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, CauseOfTransmission = asdu?.CauseOfTransmission, CauseDetails = asdu?.CauseDetails, CommandDetails = asdu?.CommandDetails, + AsduDetails = asdu, + InformationObjectDetails = asdu?.FirstObject, QualityDetails = asdu?.FirstObject?.QualityDetails, CauseName = asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = asdu?.OriginatorAddress, @@ -895,6 +899,11 @@ private void AddAdditionalObjectEvents(Ft12FrameDecode decoded, Iec10xAsduDecode IsSequenceAsdu = asdu.IsSequence, ObjectCount = asdu.ObjectCount, CauseOfTransmission = asdu.CauseOfTransmission, + CauseDetails = asdu.CauseDetails, + CommandDetails = Iec10xCommandDetails.Decode(asdu.TypeId, obj, asdu.CauseDetails!), + AsduDetails = asdu, + InformationObjectDetails = obj, + QualityDetails = obj.QualityDetails, CauseName = asdu.CotNameWithFlags, OriginatorAddress = asdu.OriginatorAddress, CommonAddressNumber = asdu.CommonAddress, @@ -1084,6 +1093,8 @@ private void AddEvent(Iec103MasterEvidenceEvent item) CauseOfTransmission = item.CauseOfTransmission, CauseDetails = item.CauseDetails, CommandDetails = item.CommandDetails, + AsduDetails = item.AsduDetails, + InformationObjectDetails = item.InformationObjectDetails, QualityDetails = item.QualityDetails, CauseName = item.CauseName, OriginatorAddress = item.OriginatorAddress, diff --git a/src/ARIEC60870.Master/Iec104ClientSession.cs b/src/ARIEC60870.Master/Iec104ClientSession.cs index c064452..b8ed397 100644 --- a/src/ARIEC60870.Master/Iec104ClientSession.cs +++ b/src/ARIEC60870.Master/Iec104ClientSession.cs @@ -258,6 +258,8 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, CauseOfTransmission = decoded.Asdu?.CauseOfTransmission, CauseDetails = decoded.Asdu?.CauseDetails, CommandDetails = decoded.Asdu?.CommandDetails, + AsduDetails = decoded.Asdu, + InformationObjectDetails = decoded.Asdu?.FirstObject, QualityDetails = decoded.Asdu?.FirstObject?.QualityDetails, CauseName = decoded.Asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = decoded.Asdu?.OriginatorAddress, @@ -338,6 +340,8 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, CauseOfTransmission = decoded.Asdu?.CauseOfTransmission, CauseDetails = decoded.Asdu?.CauseDetails, CommandDetails = decoded.Asdu?.CommandDetails, + AsduDetails = decoded.Asdu, + InformationObjectDetails = decoded.Asdu?.FirstObject, QualityDetails = decoded.Asdu?.FirstObject?.QualityDetails, CauseName = decoded.Asdu?.CotNameWithFlags ?? string.Empty, OriginatorAddress = decoded.Asdu?.OriginatorAddress, @@ -394,6 +398,11 @@ private void AddAdditionalObjectEvents(Iec104ApduDecode decoded, IReadOnlyList Frame?.LinkControl?.Acd; public bool? Dfc => Frame?.LinkControl?.Dfc; diff --git a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs index 0055082..678493b 100644 --- a/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs +++ b/src/ARIEC60870.Master/Protocol/Iec10x/Iec10xAsduDecoder.cs @@ -128,7 +128,7 @@ private IReadOnlyList DecodeObjects(byte type, byte[] r var elementOffset = offset; var remaining = raw.Length - offset; var take = dataLength >= 0 ? Math.Min(dataLength, Math.Max(0, remaining)) : Math.Max(0, remaining); - var elementRaw = raw.Skip(offset).Take(take).ToArray(); + var elementRaw = raw.AsSpan(offset, take).ToArray(); if (dataLength >= 0) { if (remaining < dataLength) @@ -347,7 +347,7 @@ public static string DecodeCp24Time2a(ReadOnlySpan data) var millisecond = ms % 1000; var minuteInvalid = (data[2] & 0x80) != 0; var minute = data[2] & 0x3F; - var invalid = minuteInvalid ? " invalid-time" : string.Empty; + var invalid = minuteInvalid || minute > 59 || ms >= 60000 ? " invalid-time" : string.Empty; return $"CP24 minute={minute:00}, second={second:00}.{millisecond:000}{invalid}"; } @@ -362,11 +362,14 @@ public static string DecodeCp56Time2a(ReadOnlySpan data) var minuteInvalid = (data[2] & 0x80) != 0; var minute = data[2] & 0x3F; var hour = data[3] & 0x1F; - var dayInvalid = (data[4] & 0x80) != 0; + // CP56 day-of-week occupies day octet bits 5..7: bit 7 is NOT invalid. var day = data[4] & 0x1F; var month = data[5] & 0x0F; var year = 2000 + (data[6] & 0x7F); - var invalid = minuteInvalid || dayInvalid ? " invalid-time" : string.Empty; + var validDate = month is >= 1 and <= 12 && day >= 1 && + day <= DateTime.DaysInMonth(year, Math.Clamp(month, 1, 12)); + var invalid = minuteInvalid || minute > 59 || ms >= 60000 || hour > 23 || !validDate + ? " invalid-time" : string.Empty; return $"{year:0000}-{month:00}-{day:00} {hour:00}:{minute:00}:{second:00}.{millisecond:000}{invalid}"; } diff --git a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs index d4b8edb..7f814d8 100644 --- a/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs +++ b/tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs @@ -171,6 +171,53 @@ public void TimeTaggedBitstringCommandCarriesCp56WithoutFalseQuality() Assert.Contains("Quality=N/A", decoded.Objects[0].ReadableSummary); } + + [Fact] + public void Cp56DayOfWeekHighBitMustNotBecomeInvalidFlag() + { + // On-wire DOW=4 lives in the high three bits of the day octet. + var timestamp = Iec10xAsduDecoder.DecodeCp56Time2a( + new byte[] { 0, 0, 30, 12, 0x9C, 10, 26 }); + Assert.Contains("2026-10-28", timestamp); + Assert.DoesNotContain("invalid-time", timestamp); + } + + [Theory] + [InlineData(0, 0, 0x80, 12, 28, 10, 26)] + [InlineData(0, 0, 60, 12, 28, 10, 26)] + [InlineData(0, 0, 30, 24, 28, 10, 26)] + [InlineData(0, 0, 30, 12, 31, 2, 26)] + [InlineData(0x60, 0xEA, 30, 12, 28, 10, 26)] + public void Cp56FlagsOrOutOfRangeCalendarRemainInvalid( + byte msLow, byte msHigh, byte minute, byte hour, byte day, byte month, byte year) + { + var timestamp = Iec10xAsduDecoder.DecodeCp56Time2a( + new byte[] { msLow, msHigh, minute, hour, day, month, year }); + Assert.Contains("invalid-time", timestamp); + } + + [Fact] + public void Sq0MultipleIoasHaveIndependentQualityAndExactOffsets() + { + byte[] asdu = + { + 9, 2, 0x83, 0, 5, 0, // Type9, VSQ2, spontaneous TEST, CA5 + 42, 0, 0, 0x10, 0, 0x00, // IOA42 quality Good + 43, 0, 0, 0x20, 0, 0x81 // IOA43 quality IV+OV + }; + var d = _decoder.Decode(asdu); + Assert.Equal(2, d.Objects.Count); + Assert.True(d.CauseDetails!.Test); + Assert.Equal(42, d.Objects[0].InformationObjectAddress); + Assert.Equal(43, d.Objects[1].InformationObjectAddress); + Assert.Equal(9, d.Objects[0].InformationElementOffset); + Assert.Equal(15, d.Objects[1].InformationElementOffset); + Assert.Equal(12, d.Objects[1].InformationObjectOffset); + Assert.Equal("Good", d.Objects[0].QualityDetails!.DisplayText); + Assert.True(d.Objects[1].QualityDetails!.Invalid); + Assert.True(d.Objects[1].QualityDetails!.Overflow == true); + } + private static Iec103MasterSettings Settings() => new() { ProtocolMode = Iec60870ProtocolMode.Iec101, From 7912e604c8cafd400ebd1e39b20646f6d229a98d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 28 Oct 2026 20:26:22 +0700 Subject: [PATCH 23/23] test(iec101): isolate byte-exact capture proof from backpressure; preserve time validity and IEC-103 E5 semantics --- .../Features/MainWindow.FrameInspector.cs | 8 +++++--- .../Iec101/Redundancy/Iec101DualLinkChannel.cs | 2 ++ src/ARIEC60870.Master/Iec101MasterSession.cs | 2 ++ src/ARIEC60870.Master/Iec104ClientSession.cs | 2 ++ tests/ARIEC60870.Monitor.Tests/PassiveMonitorP0Tests.cs | 5 ++++- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs index 649be09..8139073 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs @@ -317,9 +317,11 @@ private static IEnumerable BuildProtocolMapLines(EvidenceRow ro if (bytes[0].Equals("E5", StringComparison.OrdinalIgnoreCase)) { - yield return new ProtocolMapLine("envelope", "E5 short response", - "IEC-101 E5 has context-dependent meaning: link acknowledgement or NO DATA for a pending Class 1/2 request. " + - "Without an unambiguous matched request the application outcome is unknown; it does not prove command execution.", "E5"); + var meaning = row.ProtocolMode == "101" + ? "IEC-101 E5 is contextual: link acknowledgement or NO DATA after a pending Class 1/2 poll. " + + "Without an unambiguous matched request, the reply outcome remains unknown." + : "IEC-103 E5 single-character link acknowledgement; it does not prove an application command or physical operation succeeded."; + yield return new ProtocolMapLine("envelope", "E5 short response", meaning, "E5"); yield break; } diff --git a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs index 5152585..47dddfe 100644 --- a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs +++ b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs @@ -494,6 +494,7 @@ private async Task ReceiveOneAsync(string dataClass SignalDisplayValue = asdu?.FirstObject?.ShortValue ?? asdu?.ValueText ?? string.Empty, SignalRawValue = asdu?.FirstObject?.ElementSummary ?? asdu?.ObjectSummary ?? string.Empty, RelayTimestampText = asdu?.FirstObject?.TimestampText ?? string.Empty, + RelayTimestampInvalid = asdu?.FirstObject?.TimestampText.Contains("invalid", StringComparison.OrdinalIgnoreCase) == true, EdgeReason = asdu?.CauseName ?? string.Empty }); PublishAdditionalObjectEvents(decoded, asdu, raw!, dataClass, reason, sw.ElapsedMilliseconds); @@ -620,6 +621,7 @@ private void PublishAdditionalObjectEvents(Ft12FrameDecode decoded, Iec10xAsduDe SignalDisplayValue = obj.ShortValue, SignalRawValue = obj.ElementSummary, RelayTimestampText = obj.TimestampText, + RelayTimestampInvalid = obj.TimestampText.Contains("invalid", StringComparison.OrdinalIgnoreCase), EdgeReason = asdu.CauseName }); } diff --git a/src/ARIEC60870.Master/Iec101MasterSession.cs b/src/ARIEC60870.Master/Iec101MasterSession.cs index 576d55f..1548984 100644 --- a/src/ARIEC60870.Master/Iec101MasterSession.cs +++ b/src/ARIEC60870.Master/Iec101MasterSession.cs @@ -830,6 +830,7 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, SignalRawValue = asdu?.FirstObject?.ElementSummary ?? asdu?.ObjectSummary ?? string.Empty, QualityText = asdu?.FirstObject?.QualityText ?? string.Empty, RelayTimestampText = asdu?.FirstObject?.TimestampText ?? string.Empty, + RelayTimestampInvalid = asdu?.FirstObject?.TimestampText.Contains("invalid", StringComparison.OrdinalIgnoreCase) == true, EdgeReason = asdu?.CauseName ?? string.Empty }); AddAdditionalObjectEvents(decoded, asdu, raw!, dataClass, reason, sw.ElapsedMilliseconds); @@ -919,6 +920,7 @@ private void AddAdditionalObjectEvents(Ft12FrameDecode decoded, Iec10xAsduDecode SignalDisplayValue = obj.ShortValue, SignalRawValue = obj.ElementSummary, RelayTimestampText = obj.TimestampText, + RelayTimestampInvalid = obj.TimestampText.Contains("invalid", StringComparison.OrdinalIgnoreCase), EdgeReason = asdu.CauseName }); } diff --git a/src/ARIEC60870.Master/Iec104ClientSession.cs b/src/ARIEC60870.Master/Iec104ClientSession.cs index b8ed397..1b5e0b8 100644 --- a/src/ARIEC60870.Master/Iec104ClientSession.cs +++ b/src/ARIEC60870.Master/Iec104ClientSession.cs @@ -358,6 +358,7 @@ private async Task SendRawAsync(byte[] frame, string summary, string dataClass, SignalRawValue = decoded.Asdu?.FirstObject?.ElementSummary ?? decoded.Asdu?.ObjectSummary ?? string.Empty, QualityText = decoded.Asdu?.FirstObject?.QualityText ?? string.Empty, RelayTimestampText = decoded.Asdu?.FirstObject?.TimestampText ?? string.Empty, + RelayTimestampInvalid = decoded.Asdu?.FirstObject?.TimestampText.Contains("invalid", StringComparison.OrdinalIgnoreCase) == true, EdgeReason = decoded.Asdu?.CauseName ?? string.Empty }); AddAdditionalObjectEvents(decoded, raw, dataClass, reason, sw.ElapsedMilliseconds); @@ -418,6 +419,7 @@ private void AddAdditionalObjectEvents(Iec104ApduDecode decoded, IReadOnlyList