diff --git a/docs/IEC101_TRACE_MULTILINE_CONTRACT_20261029.md b/docs/IEC101_TRACE_MULTILINE_CONTRACT_20261029.md new file mode 100644 index 0000000..925e6c7 --- /dev/null +++ b/docs/IEC101_TRACE_MULTILINE_CONTRACT_20261029.md @@ -0,0 +1,27 @@ +# IEC-101 Frame Trace — contract: exact ASE-style multilines (2026-10-29) + +Screenshot/telegram-driven requirement: **one physical FT1.2 frame = one compact four-part block**. Do not regress to a one-line summary. 1. `⇧/⇩ [PC time] human-friendly event name`; 2. `Data Link:` service, all defined bit flags and link address (+ L for variable); 3. when a decoded ASDU exists, separate `ASDU:`, `Cause of transmission:`, `Comm Addr`, and one line per information object; 4. `Hex Frame:` all original bytes. + +## Examples, protocol-correct +``` +⇧ [03:39:37] Class 2 request +Data Link: Class 2 (Respond) [PRM:1,FCV:0] [FCB:0] Data Link Addr 105 +Hex Frame: 10 4B 69 B4 16 + +⇩ [03:39:37] Class 2 Data Not Available, Class 1 Data Available! +Data Link: Nack (Data Not Available) [PRM:0,ACD:1,DFC:0] Data Link Addr 105 +Hex Frame: 10 29 69 92 16 +``` + +For valid Type 30 data, present e.g. `ASDU: Type ID 30 Count 1`, `Cause of transmission: 20 (...) | Origin: 0`, `Comm Addr 1`, `SI 8388716 1 [Valid] 10/28/26 20:39:28.460 [Valid time,Standard time]` and the whole physical frame. Multiple IOAs all belong in that one frame block; object-specific Value/Event evidence still flows independently. Open the inspector to analyze individual bit/byte groups. + +**Bit-level correction:** control 0x4B (and 0x6B) has FCV bit 4 = 0, not 1. Source HEX takes precedence over a copied human label. Control 0x5A/0x7A for Class 1 has FCV=1.\n\n**Strict integrity:** the provided example variable frame `68 14 14 68 ... 85 16` is 25 bytes long although L=20 requires 26. Its checksum does not match supplied bytes and `64 FC` in its CP56 milliseconds field exceeds 59,999 ms/minute. It MUST NOT be silently accepted as `[Valid]` just to mimic an example. Synthetic regression instead creates a valid Type 30 frame, correct checksum, CP56Time2a, QDS/SIQ, COT, origin and CA. Time-zone origin/genuine-status not encoded in CP56 alone; show validity and summer/standard flags only, never invent authenticity. + +## Architecture and performance +- Pure `Iec101TraceNarrator` receives **already-decoded typed evidence**, formats exactly once per physical frame; no new regex parser, no serial I/O or history mutation. +- `EvidenceRow` renders label, newline-separated details and full raw HEX in 3 compact WPF text rows; TX blue/RX green lightweight data triggers. No large cards. +- Frame Trace suppresses redundant **secondary per-object evidence rows** only. They remain in Values and Event Log. Primary physical event includes the entire `AsduDetails.Objects` list. +- Initial WPF blank tab fix and source-separated M1/M2 SSOT remain unchanged. Existing bounded ring 1200 rows, 75ms UI batched flush, and passive zero-TX protection retained. + +## FAT gates +Inspect real 1200-bps 8E1 SICAM link: raw FC10/FC11, FC9 with ACD/DFC, FC8 Type 30 multi-object, GI ACTCON/ACTTERM and link E5. Confirm each exact original checksum, length, time flags and link address. Test Connect without switching tabs, inactive tab retention, selection/Auto, performance under long sessions and multi-COM passive. CI passing is not a substitute for real FAT. Also verify IEC-103/104 and imported old .ariec separate from IEC-101 on-wire formatting; do not blindly interpret their raw as IEC-101 FT1.2. diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs b/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs index 2ec758a..9a4e50e 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs @@ -754,6 +754,14 @@ private TraceVerbosityMode GetTraceVerbosityMode() private bool ShouldShowInFrameTrace(Iec103MasterEvidenceEvent item, EvidenceRow row) { + // Multiple IOAs from one received ASDU are individually routed to Values + // and Event Log, but their shared physical telegram is displayed ONCE + // in Trace. The single block enumerates every decoded ASDU object. + if (item.ProtocolMode == Iec60870ProtocolMode.Iec101 && + item.AsduDetails is not null && + item.InformationObjectDetails is { Index: > 1 }) + return false; + // Trace is the byte-for-byte communication line monitor, not a proof // summary. A valid TX/RX raw frame MUST be inspectable even if the user // is currently in Values, Events or Report. The bounded ring limits RAM. diff --git a/src/ARIEC60870.Desktop/MainWindow.xaml b/src/ARIEC60870.Desktop/MainWindow.xaml index c31c563..7a483ec 100644 --- a/src/ARIEC60870.Desktop/MainWindow.xaml +++ b/src/ARIEC60870.Desktop/MainWindow.xaml @@ -519,11 +519,25 @@ + TextOptions.TextFormattingMode="Display"> + + + + - - - + + diff --git a/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs b/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs index 9c04077..4d4bd44 100644 --- a/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs +++ b/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs @@ -84,7 +84,7 @@ public EvidenceRow(Iec103MasterEvidenceEvent item, Iec10xPointMappingEntry? ioaP ProtocolTraceTitle = narration?.Title ?? BuildProtocolTraceTitle(); ProtocolTraceMeaning = narration?.Meaning ?? BuildProtocolTraceMeaning(); ProtocolTraceRaw = BuildProtocolTraceRaw(); - ProtocolTraceMeta = $"#{Sequence} {Time} {ProtocolName}"; + ProtocolTraceMeta = narration is null ? $"#{Sequence} {Time} {ProtocolName}" : string.Empty; TrafficTone = ResolveTrafficTone(); } @@ -280,6 +280,27 @@ private string BuildProtocolTraceMeaning() private string BuildProtocolTraceRaw() { var raw = CleanTracePart(RawHex); + if (ProtocolMode == "101") + { + // Raw bytes always come from captured frames. Passive replay keeps a + // compact hexadecimal string; normalize it once for readable byte groups. + if (raw != "-" && !raw.Contains(' ') && Source.Frame?.RawBytes is { Count: > 0 } bytes) + { + raw = string.Create(bytes.Count * 3 - 1, bytes, static (chars, source) => + { + const string digits = "0123456789ABCDEF"; + for (var i = 0; i < source.Count; i++) + { + var value = source[i]; + chars[i * 3] = digits[value >> 4]; + chars[i * 3 + 1] = digits[value & 15]; + if (i + 1 < source.Count) chars[i * 3 + 2] = ' '; + } + }); + } + return "Hex Frame: " + raw; + } + var prefix = CleanTracePart(LinkFlags); var rawLine = raw == "-" ? "RAW -" : "RAW " + raw; return prefix == "-" ? rawLine : $"LINK {prefix} · {rawLine}"; diff --git a/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs b/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs index 555211e..9370d60 100644 --- a/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs +++ b/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs @@ -1,119 +1,251 @@ // Copyright 2026 Ari Sulistiono // SPDX-License-Identifier: Apache-2.0 +using System.Globalization; +using System.Text; using ARIEC60870.Core.Model; using ARIEC60870.Master.Model; +using ARIEC60870.Master.Protocol.Iec10x; namespace ARIEC60870.Desktop.ViewModels; /// -/// One cheap, read-only interpretation of the already decoded FT1.2/ASDU evidence. -/// The line monitor must never infer an ACTTERM, process operation or queue content -/// from a link ACK, ACD, or an unrelated command. +/// ASE-style, one-physical-frame / one-block IEC-101 trace presentation. +/// Uses the shared typed FT1.2/ASDU decoder, never infers object fields from a +/// string or rereads HEX. No UI controls, I/O, global mutable state or timers. /// public static class Iec101TraceNarrator { public static (string Title, string Meaning) Describe(Iec103MasterEvidenceEvent item) { - var direction = item.Direction == FrameDirection.MasterToSlave ? "➤ TX" : - item.Direction == FrameDirection.SlaveToMaster ? "◀ RX" : "• STATE"; - var link = item.Frame?.LinkControl; - var prefix = string.IsNullOrWhiteSpace(item.PhysicalSource) - ? direction : $"{direction} [{item.PhysicalSource}]"; - var dl = item.LinkAddress ?? item.Frame?.LinkAddress; - var dlText = dl.HasValue ? $"DL {dl.Value}" : "DL ?"; - var kind = string.IsNullOrWhiteSpace(item.DataClass) ? string.Empty : item.DataClass; - var className = kind.Contains("Class 1", StringComparison.OrdinalIgnoreCase) ? "CLASS 1" : - kind.Contains("Class 2", StringComparison.OrdinalIgnoreCase) ? "CLASS 2" : "REQUESTED CLASS"; - if (link is not null) + var arrow = item.Direction switch { - var flags = link.Prm - ? $"PRM=1, FCB={Bit(link.Fcb)}, FCV={Bit(link.Fcv)}, FC={link.FunctionCode}" - : $"PRM=0, ACD={Bit(link.Acd)}, DFC={Bit(link.Dfc)}, FC={link.FunctionCode}"; + FrameDirection.MasterToSlave => "⇧", + FrameDirection.SlaveToMaster => "⇩", + _ => "•" + }; + var prefix = $"{arrow} [{item.TimestampUtc.ToLocalTime():HH:mm:ss}] "; + if (!string.IsNullOrWhiteSpace(item.PhysicalSource)) + prefix += $"[{item.PhysicalSource}] "; - if (link.Prm && link.FunctionCode is 10 or 11) + var frame = item.Frame; + var link = frame?.LinkControl; + var address = item.LinkAddress ?? frame?.LinkAddress; + var addressText = address.HasValue ? address.Value.ToString(CultureInfo.InvariantCulture) : "?"; + + if (frame?.IsSingleCharacterAck == true) + return (prefix + "Single-character response (E5)", + "Data Link: E5 short response (request-dependent ACK / no-data); " + + "no link address encoded. Match preceding request before interpreting. " + + "Not proof of physical command execution."); + if (frame?.IsSingleCharacterNack == true) + return (prefix + "Single-character response (A2)", + "Data Link: A2 single-character negative response; verify equipment " + + "profile and raw frame before interpreting as IEC-101."); + if (frame is { IsChecksumValid: false } || frame is { IsLengthValid: false, Format: Ft12FrameFormat.Malformed }) + return (prefix + "Invalid FT1.2 frame", + "Data Link: Frame malformed or checksum/length invalid. " + + "Decoded fields are not trusted; inspect original HEX."); + + if (link is null) + return (prefix + "IEC-101 undecoded frame", + "Data Link: Function and flags unavailable. Inspect original HEX and configured link-address width."); + + var primary = $"[PRM:1,FCV:{Bit(link.Fcv)}] [FCB:{Bit(link.Fcb)}]"; + var secondary = $"[PRM:0,ACD:{Bit(link.Acd)},DFC:{Bit(link.Dfc)}]"; + var declaredLength = frame?.DeclaredLength is int len ? $" Length {len}" : ""; + var addressSuffix = $" Data Link Addr {addressText}"; + + if (link.Prm && link.FunctionCode is 10 or 11) + { + var which = link.FunctionCode == 10 ? "Class 1" : "Class 2"; + return (prefix + $"{which} request", + $"Data Link: {which} (Respond) {primary}{addressSuffix}"); + } + + if (!link.Prm && link.FunctionCode == 9) + { + var which = GetRequestedClass(item.DataClass); + var title = which + " Data Not Available"; + title += link.Acd switch { - var requested = link.FunctionCode == 10 ? "CLASS 1" : "CLASS 2"; - return ($"{prefix} {requested} REQUEST · {dlText}", - $"Master requests {requested} queued data from RTU · [{flags}]."); - } + true => ", Class 1 Data Available!", + false => ", No Class 1 Data Indicated", + _ => ", Class 1 Availability Unknown" + }; + var flow = link.Dfc == true ? " [DFC busy]" : ""; + return (prefix + title, + $"Data Link: Nack (Data Not Available) {secondary}{flow}{addressSuffix}"); + } - if (!link.Prm && link.FunctionCode == 9) + if (!link.Prm && link.FunctionCode == 8) + { + var asdu = item.AsduDetails; + var title = asdu?.TypeId switch { - var classText = className == "REQUESTED CLASS" ? "REQUESTED DATA" : $"{className} DATA"; - var queued = link.Acd == true - ? "CLASS 1 AVAILABLE (ACD=1); master should request Class 1" - : "No Class 1 availability signaled by ACD"; - var busy = link.Dfc == true ? " · RTU flow control busy (DFC=1)" : string.Empty; - return ($"{prefix} {classText} NOT AVAILABLE · {dlText}", - $"No requested data returned · {queued}{busy} · [{flags}]. This is a link response, not a process failure."); - } + 30 => "Single Point with Date/Time response", + 31 => "Double Point with Date/Time response", + 1 => "Single Point response", + 3 => "Double Point response", + 100 => "Interrogation response", + _ => asdu is not null ? $"{GetTypeLabel(asdu)} response" : "User Data response" + }; + var builder = new StringBuilder(220); + builder.Append("Data Link: User Data ").Append(secondary) + .Append(declaredLength).Append(addressSuffix); + if (asdu is not null) + AppendAsdu(builder, asdu); + else + builder.Append('\n').Append("ASDU: Not decoded; check COT, CA, IOA and link-width settings."); + return (prefix + title, builder.ToString()); + } - if (!link.Prm && link.FunctionCode == 8) + string meaning; + string label; + if (!link.Prm && link.FunctionCode == 0) + { + label = "Link ACK response"; + meaning = $"Data Link: ACK {secondary}{addressSuffix} " + + "(link confirmation only; physical command operation not proven)"; + } + else if (!link.Prm && link.FunctionCode == 1) + { + label = "Link NACK response"; + meaning = $"Data Link: NACK {secondary}{addressSuffix}"; + } + else if (link.Prm && link.FunctionCode == 0) + { + label = "Reset remote link"; + meaning = $"Data Link: Reset Remote Link {primary}{addressSuffix}"; + } + else if (link.Prm && link.FunctionCode == 9) + { + label = "Link status request"; + meaning = $"Data Link: Link Status Request {primary}{addressSuffix}"; + } + else if (!link.Prm && link.FunctionCode == 11) + { + label = "Link status response"; + meaning = $"Data Link: Link Status {secondary}{addressSuffix}"; + } + else + { + label = link.FunctionName; + meaning = $"Data Link: {link.FunctionName} " + + (link.Prm ? primary : secondary) + declaredLength + addressSuffix; + } + + if (item.AsduDetails is { } extra) + { + var builder = new StringBuilder(meaning); + AppendAsdu(builder, extra); + meaning = builder.ToString(); + } + return (prefix + label, meaning); + } + + private static void AppendAsdu(StringBuilder builder, Iec10xAsduDecode asdu) + { + builder.Append('\n').Append("ASDU: Type ID ").Append(asdu.TypeId) + .Append(" <").Append(GetTypeLabel(asdu)).Append("> Count ").Append(asdu.ObjectCount); + builder.Append('\n').Append("Cause of transmission: ").Append(asdu.CauseOfTransmission) + .Append(" (").Append(asdu.CauseName).Append(") | Origin: ") + .Append(asdu.CauseDetails?.OriginatorAddress is int origin ? origin.ToString(CultureInfo.InvariantCulture) + : asdu.CauseDetails is null ? asdu.OriginatorAddress.ToString(CultureInfo.InvariantCulture) : "N/A"); + if (asdu.IsNegativeConfirm) builder.Append(" | P/N:1 (Negative)"); + if (asdu.IsTest) builder.Append(" | TEST:1"); + builder.Append('\n').Append("Comm Addr ").Append(asdu.CommonAddress); + foreach (var obj in asdu.Objects) + AppendObject(builder, asdu.TypeId, obj); + if (asdu.Objects.Count != asdu.ObjectCount) + builder.Append('\n').Append("Decoded ").Append(asdu.Objects.Count) + .Append('/').Append(asdu.ObjectCount).Append(" objects; inspect decoder issues."); + if (asdu.Issues.Count > 0) + builder.Append('\n').Append("Decoder issues: ").Append(string.Join("; ", asdu.Issues)); + } + + private static void AppendObject(StringBuilder builder, int type, Iec10xInformationObject obj) + { + var digital = type is 1 or 2 or 3 or 4 or 30 or 31; + // DI is the operator-facing Digital Input label for both SP and DP + // data points. The exact wire encoding remains identified by Type ID. + var label = digital ? "DI" : "IOA"; + string value; + if (type is 1 or 2 or 30 && obj.RawBytes.Count > 0) + value = (obj.RawBytes[0] & 1).ToString(CultureInfo.InvariantCulture); + else if (type is 3 or 4 or 31 && obj.RawBytes.Count > 0) + value = (obj.RawBytes[0] & 3).ToString(CultureInfo.InvariantCulture); + else + value = obj.ShortValue; + if (string.IsNullOrWhiteSpace(value)) value = "(not decoded)"; + + builder.Append('\n').Append(label).Append(' ').Append(obj.InformationObjectAddress) + .Append(' ').Append(value); + if (obj.QualityDetails is { } q) + builder.Append(" [").Append(q.Invalid ? "Invalid" : q.DisplayText == "Good" ? "Valid" : "Valid, " + q.DisplayText) + .Append(']'); + else if (digital) + builder.Append(" [Quality unavailable]"); + else if (!string.IsNullOrWhiteSpace(obj.QualityText)) + builder.Append(" [").Append(obj.QualityText).Append(']'); + + if (!string.IsNullOrWhiteSpace(obj.TimestampText)) + { + var source = obj.TimestampText; + var invalid = source.Contains("invalid", StringComparison.OrdinalIgnoreCase); + if (DateTime.TryParseExact(source.Replace(" invalid-time", "", StringComparison.OrdinalIgnoreCase), + "yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + builder.Append(' ').Append(date.ToString("MM/dd/yy HH:mm:ss.fff", CultureInfo.InvariantCulture)); + else + builder.Append(' ').Append(source); + + if (HasCp56Time(type, obj.RawBytes)) { - var asdu = item.AsduDetails; - var type = asdu?.TypeName ?? item.TypeName; - if (string.IsNullOrWhiteSpace(type)) type = "ASDU"; - var cause = asdu?.CotDisplay ?? (item.CauseOfTransmission.HasValue - ? $"{item.CauseOfTransmission.Value} · {item.CauseName}" : "COT unknown"); - var ca = asdu?.CommonAddress ?? item.CommonAddressNumber; - var first = item.InformationObjectDetails ?? asdu?.FirstObject; - var objectCount = asdu?.Objects.Count ?? item.ObjectCount ?? 0; - var details = $"ASDU Type {asdu?.TypeId ?? item.TypeId}: {type}; COT {cause}; CA={ca?.ToString() ?? "?"}; " + - $"objects={objectCount}; [{flags}]"; - if (first is not null) + var timeByteOffset = type switch + { + 30 or 31 or 58 or 59 or 60 => 1, + 32 => 2, + 34 or 35 or 61 or 62 => 3, + 36 or 37 or 33 or 63 => 5, + 64 => 4, + 103 => 0, + _ => -1 + }; + if (timeByteOffset >= 0 && obj.RawBytes.Count >= timeByteOffset + 7) { - details += $"; IOA {first.InformationObjectAddress}={first.ShortValue}"; - if (first.QualityDetails is not null) details += $"; Quality={first.QualityDetails.DisplayText}"; - if (!string.IsNullOrWhiteSpace(first.TimestampText)) details += $"; RTU time={first.TimestampText}"; + var minuteFlags = obj.RawBytes[timeByteOffset + 2]; + var hourFlags = obj.RawBytes[timeByteOffset + 3]; + invalid |= (minuteFlags & 0x80) != 0; + builder.Append(invalid ? " [Invalid time" : " [Valid time"); + builder.Append((hourFlags & 0x80) != 0 ? ",Summer time]" : ",Standard time]"); } - if (link.Acd == true) details += "; additional Class 1 data indicated"; - return ($"{prefix} RTU USER DATA · {type} · {dlText}", details + "."); } - - if (!link.Prm && link.FunctionCode == 0) - return ($"{prefix} LINK ACK · {dlText}", - $"RTU acknowledged the link-layer request · [{flags}]. This does not prove application or physical command success."); - - if (!link.Prm && link.FunctionCode == 1) - return ($"{prefix} LINK NACK · {dlText}", - $"RTU reported negative link confirmation · [{flags}]. Check the preceding request and link state."); - - if (link.Prm && link.FunctionCode == 0) - return ($"{prefix} RESET REMOTE LINK · {dlText}", - $"Master requests link reset · [{flags}]. Await RTU link response."); - if (link.Prm && link.FunctionCode == 9) - return ($"{prefix} LINK STATUS REQUEST · {dlText}", - $"Master requests link status · [{flags}]."); - if (!link.Prm && link.FunctionCode == 11) - return ($"{prefix} LINK STATUS RESPONSE · {dlText}", - $"RTU reports link status · [{flags}]."); - if (item.AsduDetails is not null) - { - var asdu = item.AsduDetails; - return ($"{prefix} {asdu.TypeName} · {dlText}", - $"ASDU Type {asdu.TypeId}, COT {asdu.CotDisplay}, CA={asdu.CommonAddress}, " + - $"objects={asdu.Objects.Count} · [{flags}]. {asdu.ShortMeaning}"); - } - return ($"{prefix} {link.FunctionName.ToUpperInvariant()} · {dlText}", - $"Link function FC={link.FunctionCode} · [{flags}]."); + else if (invalid) builder.Append(" [Invalid time]"); } - if (item.Frame?.IsSingleCharacterAck == true) - return ($"{prefix} E5 SHORT RESPONSE", - "IEC-101 E5 short response: meaning depends on the matched link request. " + - "It must not be presented as proof that an application command succeeded."); + if (obj.Issues.Count > 0) + builder.Append(" [Decode issue: ").Append(string.Join("; ", obj.Issues)).Append(']'); + } - if (item.Frame?.IsSingleCharacterNack == true) - return ($"{prefix} A2 INVALID IEC-101 RESPONSE", - "A2 single-character response is not permitted in IEC-101. Keep original bytes for investigation."); + private static bool HasCp56Time(int type, IReadOnlyList data) => + type is >= 30 and <= 37 or >= 58 and <= 64 or 103 && data.Count >= 7; - if (item.Frame is { IsChecksumValid: false }) - return ($"{prefix} INVALID FT1.2 FRAME", "Checksum/format invalid or incomplete; consult exact raw bytes."); + private static string GetTypeLabel(Iec10xAsduDecode asdu) => asdu.TypeId switch + { + 1 => "Single-point", + 3 => "Double-point", + 30 => "Single-point with date/time", + 31 => "Double-point with date/time", + 100 => "Interrogation command", + _ => asdu.TypeName + }; - return ($"{prefix} IEC-101 FRAME", - string.IsNullOrWhiteSpace(item.ProtocolMeaning) ? item.Summary : item.ProtocolMeaning); + private static string GetRequestedClass(string? dataClass) + { + if (dataClass?.Contains("Class 1", StringComparison.OrdinalIgnoreCase) == true) return "Class 1"; + if (dataClass?.Contains("Class 2", StringComparison.OrdinalIgnoreCase) == true) return "Class 2"; + return "Requested"; } - private static string Bit(bool? value) => value is null ? "?" : value.Value ? "1" : "0"; + private static string Bit(bool? bit) => bit.HasValue ? bit.Value ? "1" : "0" : "?"; } diff --git a/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs b/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs index 9388b98..4e0275a 100644 --- a/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs +++ b/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using ARIEC60870.Core.Model; +using ARIEC60870.Core.Parsing; using ARIEC60870.Desktop.ViewModels; using ARIEC60870.Master.Model; using ARIEC60870.Master.Protocol.Iec10x; @@ -11,113 +12,192 @@ namespace ARIEC60870.Desktop.Tests; public sealed class Iec101TraceNarratorTests { - private static Iec103MasterEvidenceEvent LinkFrame(bool primary, int function, string dataClass = "Class 2", - bool acd = false, bool dfc = false) => new() + private static readonly DateTime PcTime = new(2026, 10, 28, 20, 39, 37, DateTimeKind.Utc); + + private static Iec103MasterEvidenceEvent LinkFrame(string hex, string dataClass) { - ProtocolMode = Iec60870ProtocolMode.Iec101, - Direction = primary ? FrameDirection.MasterToSlave : FrameDirection.SlaveToMaster, - DataClass = dataClass, - LinkAddress = 105, - RawHex = "10 09 69 00 72 16", - Frame = new Ft12FrameDecode + var bytes = Convert.FromHexString(hex.Replace(" ", "", StringComparison.Ordinal)); + var frame = new Ft12Parser(1).Decode(bytes); + Assert.True(frame.IsChecksumValid); + return new Iec103MasterEvidenceEvent { - IsChecksumValid = true, - LinkAddress = 105, - LinkControl = new LinkControlInfo - { - Prm = primary, FunctionCode = function, - Fcb = primary ? true : null, Fcv = primary ? true : null, - Acd = primary ? null : acd, Dfc = primary ? null : dfc, - FunctionName = "IEC-101 link function" - } - } - }; + ProtocolMode = Iec60870ProtocolMode.Iec101, + TimestampUtc = PcTime, + Direction = frame.LinkControl?.Prm == true + ? FrameDirection.MasterToSlave + : FrameDirection.SlaveToMaster, + Frame = frame, + LinkAddress = frame.LinkAddress, + DataClass = dataClass, + RawHex = hex + }; + } + + [Theory] + [InlineData("10 4B 69 B4 16", "FCB:0")] + [InlineData("10 6B 69 D4 16", "FCB:1")] + public void Class2RequestMatchesExactlyTheUsersThreeLinePattern(string hex, string fcb) + { + var f = LinkFrame(hex, "Class 2"); + var (title, meaning) = Iec101TraceNarrator.Describe(f); + Assert.Contains("⇧ [", title); + Assert.EndsWith("Class 2 request", title); + // The 4B and 6B control bytes have FCV bit 4 CLEAR (FCV=0). + // Preserve actual wire bits even if a pasted line-monitor example says FCV=1. + Assert.Equal($"Data Link: Class 2 (Respond) [PRM:1,FCV:0] [{fcb}] Data Link Addr 105", meaning); + var row = new EvidenceRow(f); + Assert.Equal("Hex Frame: " + hex, row.ProtocolTraceRaw); + Assert.Empty(row.ProtocolTraceMeta); + } [Fact] - public void EveryClass2RequestHasDirectionLinkAndRawProtocolFlags() + public void Class2NoDataAndAcd1ShowsAvailableClass1WithoutClaimingDeliveredData() { - var presentation = Iec101TraceNarrator.Describe(LinkFrame(true, 11)); - Assert.Contains("➤ TX", presentation.Title); - Assert.Contains("CLASS 2 REQUEST", presentation.Title); - Assert.Contains("DL 105", presentation.Title); - Assert.Contains("PRM=1", presentation.Meaning); - Assert.Contains("FCV=1", presentation.Meaning); + var f = LinkFrame("10 29 69 92 16", "Class 2"); + var (title, meaning) = Iec101TraceNarrator.Describe(f); + Assert.Contains("⇩ [", title); + Assert.EndsWith("Class 2 Data Not Available, Class 1 Data Available!", title); + Assert.Equal("Data Link: Nack (Data Not Available) [PRM:0,ACD:1,DFC:0] Data Link Addr 105", meaning); } [Fact] - public void Class2NoDataWithAcdMeansClass1QueueIsIndicatedNotAnError() + public void Class1RequestDecodesFcbAndNoIncorrectAcd() { - var presentation = Iec101TraceNarrator.Describe(LinkFrame(false, 9, "Class 2", acd: true)); - Assert.Contains("◀ RX", presentation.Title); - Assert.Contains("CLASS 2 DATA NOT AVAILABLE", presentation.Title); - Assert.Contains("CLASS 1 AVAILABLE", presentation.Meaning); - Assert.Contains("ACD=1", presentation.Meaning); - Assert.Contains("DFC=0", presentation.Meaning); - Assert.Contains("not a process failure", presentation.Meaning); + var first = Iec101TraceNarrator.Describe(LinkFrame("10 5A 69 C3 16", "Class 1")); + var next = Iec101TraceNarrator.Describe(LinkFrame("10 7A 69 E3 16", "Class 1")); + Assert.EndsWith("Class 1 request", first.Title); + Assert.Contains("FCB:0", first.Meaning); + Assert.Contains("FCB:1", next.Meaning); + Assert.DoesNotContain("ACD:", first.Meaning); } [Fact] - public void UnknownPollClassMustNotBeClaimedClass2() + public void UnknownPollContextMustNotFabricateClass() + { + var title = Iec101TraceNarrator.Describe(LinkFrame("10 29 69 92 16", "-")).Title; + Assert.Contains("Requested Data Not Available", title); + Assert.DoesNotContain("Class 2 Data Not Available", title); + } + + private static byte[] MakeVariableFrame(byte linkControl, byte linkAddress, byte[] asdu) { - var presentation = Iec101TraceNarrator.Describe(LinkFrame(false, 9, "-")); - Assert.Contains("REQUESTED DATA NOT AVAILABLE", presentation.Title); - Assert.DoesNotContain("CLASS 2 DATA NOT AVAILABLE", presentation.Title); + var count = asdu.Length + 2; + var raw = new byte[count + 6]; + raw[0] = 0x68; raw[1] = (byte)count; raw[2] = (byte)count; raw[3] = 0x68; + raw[4] = linkControl; raw[5] = linkAddress; + asdu.CopyTo(raw, 6); + var checksum = 0; + for (var i = 4; i < count + 4; i++) checksum += raw[i]; + raw[count + 4] = (byte)checksum; + raw[count + 5] = 0x16; + return raw; } [Fact] - public void RTUAsduKeepsTypeCotCaIoaAndValueWithoutReparsingHex() + public void SinglePointCp56ResponseShowsAsduCotOriginCaDiQualityTimeAndFullHex() { - var asdu = new Iec10xAsduDecode - { - TypeId = 30, TypeName = "M_SP_TB_1", CauseOfTransmission = 20, - CauseName = "interrogated by station", CommonAddress = 1, - Objects = new[] { new Iec10xInformationObject - { Index = 1, InformationObjectAddress = 8388715, EngineeringValue = "ON", - TimestampText = "2026-10-28 20:39:28.460" } } + // An ACTUAL structurally valid Type 30 telegram, not the truncated + // / bad-checksum hex in the screen-copy example. + var asduBytes = new byte[] { + 0x1E, 0x01, 0x14, 0x00, 0x01, 0x00, + 0x6C, 0x00, 0x80, 0x01, + 0x2C, 0x6F, 0x27, 0x14, 0x1C, 0x0A, 0x1A }; - var basic = LinkFrame(false, 8, "Class 1", acd: true); - var item = new Iec103MasterEvidenceEvent + var raw = MakeVariableFrame(0x28, 0x69, asduBytes); + var frame = new Ft12Parser(1).Decode(raw); + var asdu = new Iec10xAsduDecoder(2, 2, 3).Decode(asduBytes); + Assert.True(frame.IsChecksumValid); + Assert.True(frame.IsLengthValid); + Assert.Empty(asdu.Issues); + var e = new Iec103MasterEvidenceEvent { ProtocolMode = Iec60870ProtocolMode.Iec101, + TimestampUtc = PcTime, Direction = FrameDirection.SlaveToMaster, - DataClass = basic.DataClass, - LinkAddress = basic.LinkAddress, - Frame = basic.Frame, - AsduDetails = asdu + Frame = frame, + LinkAddress = 105, + AsduDetails = asdu, + RawHex = Convert.ToHexString(raw), + DataClass = "Class 1" }; - var presentation = Iec101TraceNarrator.Describe(item); - Assert.Contains("RTU USER DATA", presentation.Title); - Assert.Contains("ASDU Type 30", presentation.Meaning); - Assert.Contains("COT 20", presentation.Meaning); - Assert.Contains("CA=1", presentation.Meaning); - Assert.Contains("IOA 8388715=ON", presentation.Meaning); - Assert.Contains("RTU time=2026-10-28", presentation.Meaning); + var row = new EvidenceRow(e); + Assert.EndsWith("Single Point with Date/Time response", row.ProtocolTraceTitle); + Assert.Contains("Data Link: User Data [PRM:0,ACD:1,DFC:0] Length 19 Data Link Addr 105", row.ProtocolTraceMeaning); + Assert.Contains("ASDU: Type ID 30 Count 1", row.ProtocolTraceMeaning); + Assert.Contains("Cause of transmission: 20 (interrogated by station interrogation) | Origin: 0", row.ProtocolTraceMeaning); + Assert.Contains("Comm Addr 1", row.ProtocolTraceMeaning); + Assert.Contains("DI 8388716 1 [Valid] 10/28/26 20:39:28.460 [Valid time,Standard time]", row.ProtocolTraceMeaning); + Assert.StartsWith("Hex Frame: 68 13 13 68 28 69", row.ProtocolTraceRaw); + Assert.EndsWith(" 16", row.ProtocolTraceRaw); } [Fact] - public void LinkAckNeverClaimsSuccessfulPhysicalOperation() + public void AllObjectsInSameAsduArePrintedWithinOneTraceBlock() { - var presentation = Iec101TraceNarrator.Describe(LinkFrame(false, 0)); - Assert.Contains("LINK ACK", presentation.Title); - Assert.Contains("does not prove", presentation.Meaning); + var asdu = new Iec10xAsduDecoder().Decode(new byte[] { + 1, 2, 3, 0, 1, 0, + 10, 0, 0, 1, + 11, 0, 0, 0 + }); + var f = LinkFrame("10 29 69 92 16", "Class 1"); + var e = new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + TimestampUtc = PcTime, + Direction = FrameDirection.SlaveToMaster, + Frame = new Ft12FrameDecode + { + LinkControl = new LinkControlInfo + { + Prm = false, + FunctionCode = 8, + Acd = true, + Dfc = false + }, + LinkAddress = 105, + IsChecksumValid = true, + IsLengthValid = true + }, + AsduDetails = asdu + }; + var block = Iec101TraceNarrator.Describe(e).Meaning; + Assert.Contains("DI 10 1 [Valid]", block); + Assert.Contains("DI 11 0 [Valid]", block); } [Fact] - public void E5ResponseIsExplicitlyContextDependent() + public void LinkAckAndE5NeverProvePhysicalCommandOperation() { - var item = new Iec103MasterEvidenceEvent + var ack = LinkFrame("10 00 69 69 16", "Link"); + var title = Iec101TraceNarrator.Describe(ack); + Assert.Contains("Link ACK response", title.Title); + Assert.Contains("not proven", title.Meaning); + + var e5 = new Iec103MasterEvidenceEvent { ProtocolMode = Iec60870ProtocolMode.Iec101, Direction = FrameDirection.SlaveToMaster, - Frame = new Ft12FrameDecode - { - Format = Ft12FrameFormat.SingleCharacter, - RawBytes = new byte[] { 0xE5 }, - IsChecksumValid = true - } + TimestampUtc = PcTime, + Frame = new Ft12Parser(1).Decode(new byte[] { 0xE5 }) }; - var presentation = Iec101TraceNarrator.Describe(item); - Assert.Contains("E5 SHORT RESPONSE", presentation.Title); - Assert.Contains("matched link request", presentation.Meaning); + var e5Text = Iec101TraceNarrator.Describe(e5); + Assert.Contains("Single-character response (E5)", e5Text.Title); + Assert.Contains("Match preceding request", e5Text.Meaning); + } + + [Fact] + public void MalformedUserSampleMustNotBeFalselyMarkedValid() + { + var provided = Convert.FromHexString( + "6814146808691E01140001006C00800164FCCD4B1E1A1A8516"); + var frame = new Ft12Parser(1).Decode(provided); + Assert.False(frame.IsLengthValid && frame.IsChecksumValid); + var title = Iec101TraceNarrator.Describe(new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + Frame = frame, + Direction = FrameDirection.SlaveToMaster + }).Title; + Assert.Contains("Invalid FT1.2 frame", title); } }