diff --git a/docs/IEC101_LIVE_TRACE_ASE2000_PARITY_20261029.md b/docs/IEC101_LIVE_TRACE_ASE2000_PARITY_20261029.md new file mode 100644 index 0000000..e1f18af --- /dev/null +++ b/docs/IEC101_LIVE_TRACE_ASE2000_PARITY_20261029.md @@ -0,0 +1,24 @@ +# Live Trace recovery and IEC-101 interpretive line monitor — 2026-10-29 + +## Problem reproduced in screenshots +Active IEC-101 TX/RX counters and LED traffic are live, but the Trace workspace remains visually blank until changing tabs. The old UI retained Hold/selected-row freeze state across connections; first-batch range notifications to the still-mounted virtualized WPF ListBox could leave the initially cleared viewport unrealized. In addition, Balanced verbosity *silently omitted* routine polling when the Trace workspace was not active. + +## Corrected architecture +- **PrepareLiveTraceForConnect** resets selection, Hold and deferred view flags only at connection start (active, dual-link and passive), never on every frame. +- Exactly one dispatcher-priority initial evidence flush after Connect. Continuous traffic remains handled by the existing bounded 75ms timer, adaptive processing budget and deferred UI batch updates, without a per-frame `Dispatcher.Invoke`. +- The first Trace batch is materialized atomically from the authoritative 1200-row bounded ring and schedules one deferred layout invalidation. Subsequent batches append/trim only. Switching workspaces still snapshots that same ring; there is no separate tab-dependent decoder or storage. +- **Every TX/RX raw frame** is retained in the bounded Trace store regardless of which workspace is visible. Backpressure protection remains separately counted and does not silently discard raw bytes from the capture journal. +- Physical poll/reply interpretation uses the existing typed FT1.2 and ASDU decode; no regex/string guess of PRM, ACD, DFC or COT. IEC-101 Class 2 FC11 request, secondary FC9 no-data, FC8 user data with type/COT/CA/IOA/value/quality/time, link ACK/NACK and link reset are explained as engineer-readable lines. E5 remains contextual: never inferred as physical command success. +- Compact TX ➤ and RX ◀ markers; raw hex and selected-byte Inspector remain directly below/alongside the line. No heavy graphic controls/animations, wide grid columns, or per-frame WPF layout refreshes. + +## FAT acceptance +1. Start IEC-101 Active Master on COM at 1200 bps with Trace already selected; first Reset Link TX and RX ACK must display **without switching tabs**. +2. Keep Trace selected during GI, FC10/11 polls and FC8/9 replies; every raw frame has a direction, protocol meaning and selectable original HEX. +3. Switch to Values/Events while polling, then back to Trace: ring snapshot must show recent traffic from the inactive period. +4. In Trace select one row: deliberate Hold preserves inspection; choose Auto or new Connect to resume live. Prior session Hold must never freeze the next session. +5. FC9 with ACD=1/DFC=0 prints that requested class has no data but Class 1 pending. Never conflate it with RTU application error. +6. Run same capture alongside ASE2000 V2 and compare FC, COT, CA, IOA, quality and CP56Time2a. Verify linked selected raw field highlights and command lifecycle separately. +7. Run stress 4-COM passive and long duration Windows integrated sessions; verify no unbounded RAM/GUI repaint. CI does not substitute real-device FAT. + +## Scope +This milestone corrects initial WPF Trace rendering and per-frame human-readable decoding for IEC-101. It does not redesign the rest of the GUI, implement a complete bidirectional byte-field highlight for every IEC-104/103 frame, or claim full ASE2000 V2 parity yet. diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs b/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs index ba4add9..2ec758a 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs @@ -221,6 +221,17 @@ private void OnEvidenceReceived(object? sender, Iec103MasterEvidenceEvent item) } _pendingEvidence.Enqueue(item); + + // First event after Connect gets one prompt dispatcher flush; normal traffic + // continues on the 75ms bounded timer. Never queue a UI callback per telegram. + if (System.Threading.Interlocked.Exchange(ref _initialLiveTraceFlushPending, 0) == 1 && + !Dispatcher.HasShutdownStarted) + { + Dispatcher.BeginInvoke(new Action(() => + { + if (_pendingEvidence.Count > 0) FlushUiQueues(); + }), DispatcherPriority.Background); + } } private void OnFindingRaised(object? sender, Iec103MasterFinding finding) @@ -526,7 +537,7 @@ private bool IsProtocolTraceViewFrozen() return IsLineMonitorHoldRequested() || _isProtocolTraceDragSelecting || _isProtocolTraceSelectionBatching - || (FrameTraceGrid?.SelectedItems.Count ?? 0) > 0 + || (_protocolTraceSelectionAnchorIndex >= 0 && (FrameTraceGrid?.SelectedItems.Count ?? 0) > 0) || FrameTraceGrid?.ContextMenu?.IsOpen == true; } @@ -573,6 +584,30 @@ private void AddProtocolTraceRow(EvidenceRow row) } } + /// + /// A new connection must never inherit a stale Hold/selection state from a + /// previous session. Trace remains live without forcing navigation to another tab. + /// + private void PrepareLiveTraceForConnect() + { + _initialLiveTraceFlushPending = 1; + _protocolTraceSelectionAnchorIndex = -1; + _evidenceSummarySelectionAnchorIndex = -1; + _isProtocolTraceDragSelecting = false; + _isProtocolTraceSelectionBatching = false; + _isEvidenceSummaryDragSelecting = false; + _pendingProtocolTraceSelectionInspectorRefresh = false; + _protocolTraceViewDirtyWhileFrozen = false; + _evidenceSummaryViewDirtyWhileFrozen = false; + _protocolTraceRowsDeferredWhileFrozen = 0; + _evidenceSummaryRowsDeferredWhileFrozen = 0; + FrameTraceGrid?.SelectedItems.Clear(); + EvidenceSummaryList?.SelectedItems.Clear(); + if (AutoScrollLatestToggleButton is not null) + AutoScrollLatestToggleButton.IsChecked = true; + UpdateAutoScrollLatestRailVisual(); + } + private void FlushVisibleUiBatches() { var batchRows = _pendingEvidenceSummaryUiRows.Count @@ -613,8 +648,25 @@ private void FlushVisibleUiBatches() else { ApplyDeferredProtocolTraceSnapshotIfNeeded(); - FrameTraceRows.AddRange(_pendingProtocolTraceUiRows); - _pendingProtocolTraceUiRows.Clear(); + if (FrameTraceRows.Count == 0) + { + // WPF ListBox can retain an unrealized empty viewport when an + // existing ItemsSource was cleared while the session changed. + // Reconcile the first batch from the authoritative bounded ring, + // once, instead of waiting for a tab switch to force a rebind. + FrameTraceRows.ReplaceRange(_protocolTraceStore.Snapshot()); + _pendingProtocolTraceUiRows.Clear(); + Dispatcher.BeginInvoke(new Action(() => + { + if (IsProtocolTraceTabActive()) + FrameTraceGrid?.InvalidateMeasure(); + }), DispatcherPriority.Loaded); + } + else + { + FrameTraceRows.AddRange(_pendingProtocolTraceUiRows); + _pendingProtocolTraceUiRows.Clear(); + } _visibleEvidenceDropped += FrameTraceRows.TrimStart(MaxVisibleFrameTraceRows); ScrollLatestProtocolTraceIfAutoScroll(); } @@ -702,56 +754,13 @@ private TraceVerbosityMode GetTraceVerbosityMode() private bool ShouldShowInFrameTrace(Iec103MasterEvidenceEvent item, EvidenceRow row) { - // Monitor mode must show real SCADA↔RTU traffic, including routine polls, - // independently of the Active Master Balanced/Proof trace suppression rules. - if (item.PollingReason == "PASSIVE-IEC101") return true; - if (row.RawHex == "-" || - (!row.Direction.Equals("TX", StringComparison.OrdinalIgnoreCase) && - !row.Direction.Equals("RX", StringComparison.OrdinalIgnoreCase))) - { - return false; - } - - var mode = GetTraceVerbosityMode(); - if (mode == TraceVerbosityMode.Full) - { - return true; - } - - if (IsProtectedTraceEvidence(item, row)) - { - return true; - } - - var combined = string.Join(" ", item.Category, item.State, item.Summary, item.Detail, item.OperatorMessage, item.OperatorAction, item.ProtocolMeaning, item.DataClass, row.ReadableMeaning); - var routinePoll = ContainsAny(combined, "Request Class 1", "Request Class 2", "Class 2 poll", "background poll", "no data", "ACK", "single-character ACK"); - var supervisory = ContainsAny(combined, "TESTFR", "S-frame", "STARTDT", "STOPDT"); - - if (mode == TraceVerbosityMode.Proof) - { - if (routinePoll || supervisory) - { - CountTraceVerbositySuppression(routinePoll ? "routine" : "supervisory"); - return false; - } - } - - if (mode == TraceVerbosityMode.Balanced) - { - if (supervisory) - { - CountTraceVerbositySuppression("supervisory"); - return false; - } - - if (routinePoll && !IsProtocolTraceTabActive()) - { - CountTraceVerbositySuppression("routine"); - return false; - } - } - - return true; + // 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. + // UI queue backpressure remains a separate, explicitly counted safeguard. + return row.RawHex != "-" && + (row.Direction.Equals("TX", StringComparison.OrdinalIgnoreCase) || + row.Direction.Equals("RX", StringComparison.OrdinalIgnoreCase)); } private static bool IsProtectedTraceEvidence(Iec103MasterEvidenceEvent item, EvidenceRow row) diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs index 5bd5a22..fb9d3d1 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs @@ -398,6 +398,7 @@ private async Task RunPassiveRecordingAsync() _passiveMonitorSession = session; _passiveCaptureResult = null; ClearSessionView(clearLog: true); + PrepareLiveTraceForConnect(); _passiveRawRows.Clear(); _passiveFrameSequence = 0; _passiveUndecodableShown = 0; diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveReplay.cs b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveReplay.cs index 541f58c..ad201a8 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveReplay.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveReplay.cs @@ -82,6 +82,7 @@ private async Task OpenPassiveRawJournalAsync(string manifestPath) try { ClearSessionView(clearLog: true); + PrepareLiveTraceForConnect(); _passiveFrameSequence = 0; _passiveTransactionNotes.Clear(); _passiveFindingLastShown.Clear(); diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.Session.cs b/src/ARIEC60870.Desktop/Features/MainWindow.Session.cs index d6ef694..e8e2a61 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.Session.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.Session.cs @@ -101,6 +101,7 @@ private async void Start_Click(object sender, RoutedEventArgs e) } ClearSessionView(clearLog: false); + PrepareLiveTraceForConnect(); SeedValueViewerFromIoaProfile(settings.ProtocolMode); _stopRequested = false; _operatorDisconnectInProgress = false; diff --git a/src/ARIEC60870.Desktop/MainWindow.xaml.cs b/src/ARIEC60870.Desktop/MainWindow.xaml.cs index 25fa419..cb09bd5 100644 --- a/src/ARIEC60870.Desktop/MainWindow.xaml.cs +++ b/src/ARIEC60870.Desktop/MainWindow.xaml.cs @@ -93,6 +93,8 @@ public partial class MainWindow : Window private readonly ConcurrentQueue _pendingEvidence = new(); + // One early dispatcher flush per Connect; normal traffic uses the bounded UI timer. + private int _initialLiveTraceFlushPending; private readonly ConcurrentQueue _pendingFindings = new(); private readonly Queue _sessionLogLines = new(); private readonly DispatcherTimer _uiFlushTimer; diff --git a/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs b/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs index 8cd3b89..9c04077 100644 --- a/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs +++ b/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs @@ -75,8 +75,14 @@ public EvidenceRow(Iec103MasterEvidenceEvent item, Iec10xPointMappingEntry? ioaP ProtocolAddress = BuildProtocolAddress(); ProtocolService = BuildProtocolService(); ReadableMeaning = BuildReadableMeaning(item); - ProtocolTraceTitle = BuildProtocolTraceTitle(); - ProtocolTraceMeaning = BuildProtocolTraceMeaning(); + // Render the already-decoded IEC-101 frame once, reusing its compact + // narration for both line title and explanation (no duplicate formatting). + (string Title, string Meaning)? narration = + item.ProtocolMode == Iec60870ProtocolMode.Iec101 && item.Frame is not null + ? Iec101TraceNarrator.Describe(item) + : null; + ProtocolTraceTitle = narration?.Title ?? BuildProtocolTraceTitle(); + ProtocolTraceMeaning = narration?.Meaning ?? BuildProtocolTraceMeaning(); ProtocolTraceRaw = BuildProtocolTraceRaw(); ProtocolTraceMeta = $"#{Sequence} {Time} {ProtocolName}"; TrafficTone = ResolveTrafficTone(); @@ -221,7 +227,8 @@ public EvidenceRow(CaptureFrameSnapshot capture) private string BuildProtocolTraceTitle() { - var direction = string.IsNullOrWhiteSpace(Direction) ? "STATE" : Direction; + var direction = Direction == "TX" ? "➤ TX" : Direction == "RX" ? "◀ RX" : + string.IsNullOrWhiteSpace(Direction) ? "STATE" : Direction; var service = CleanTracePart(ProtocolService); var address = CleanTracePart(ProtocolAddress); var signal = CleanTracePart(SignalOrAddress); diff --git a/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs b/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs new file mode 100644 index 0000000..555211e --- /dev/null +++ b/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs @@ -0,0 +1,119 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using ARIEC60870.Core.Model; +using ARIEC60870.Master.Model; + +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. +/// +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 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}"; + + if (link.Prm && link.FunctionCode is 10 or 11) + { + var requested = link.FunctionCode == 10 ? "CLASS 1" : "CLASS 2"; + return ($"{prefix} {requested} REQUEST · {dlText}", + $"Master requests {requested} queued data from RTU · [{flags}]."); + } + + if (!link.Prm && link.FunctionCode == 9) + { + 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."); + } + + if (!link.Prm && link.FunctionCode == 8) + { + 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) + { + 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}"; + } + 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}]."); + } + + 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 (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."); + + if (item.Frame is { IsChecksumValid: false }) + return ($"{prefix} INVALID FT1.2 FRAME", "Checksum/format invalid or incomplete; consult exact raw bytes."); + + return ($"{prefix} IEC-101 FRAME", + string.IsNullOrWhiteSpace(item.ProtocolMeaning) ? item.Summary : item.ProtocolMeaning); + } + + private static string Bit(bool? value) => value is null ? "?" : value.Value ? "1" : "0"; +} diff --git a/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs b/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs new file mode 100644 index 0000000..9388b98 --- /dev/null +++ b/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs @@ -0,0 +1,123 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using ARIEC60870.Core.Model; +using ARIEC60870.Desktop.ViewModels; +using ARIEC60870.Master.Model; +using ARIEC60870.Master.Protocol.Iec10x; +using Xunit; + +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() + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + Direction = primary ? FrameDirection.MasterToSlave : FrameDirection.SlaveToMaster, + DataClass = dataClass, + LinkAddress = 105, + RawHex = "10 09 69 00 72 16", + Frame = new Ft12FrameDecode + { + 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" + } + } + }; + + [Fact] + public void EveryClass2RequestHasDirectionLinkAndRawProtocolFlags() + { + 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); + } + + [Fact] + public void Class2NoDataWithAcdMeansClass1QueueIsIndicatedNotAnError() + { + 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); + } + + [Fact] + public void UnknownPollClassMustNotBeClaimedClass2() + { + var presentation = Iec101TraceNarrator.Describe(LinkFrame(false, 9, "-")); + Assert.Contains("REQUESTED DATA NOT AVAILABLE", presentation.Title); + Assert.DoesNotContain("CLASS 2 DATA NOT AVAILABLE", presentation.Title); + } + + [Fact] + public void RTUAsduKeepsTypeCotCaIoaAndValueWithoutReparsingHex() + { + 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" } } + }; + var basic = LinkFrame(false, 8, "Class 1", acd: true); + var item = new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + Direction = FrameDirection.SlaveToMaster, + DataClass = basic.DataClass, + LinkAddress = basic.LinkAddress, + Frame = basic.Frame, + AsduDetails = asdu + }; + 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); + } + + [Fact] + public void LinkAckNeverClaimsSuccessfulPhysicalOperation() + { + var presentation = Iec101TraceNarrator.Describe(LinkFrame(false, 0)); + Assert.Contains("LINK ACK", presentation.Title); + Assert.Contains("does not prove", presentation.Meaning); + } + + [Fact] + public void E5ResponseIsExplicitlyContextDependent() + { + var item = new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + Direction = FrameDirection.SlaveToMaster, + Frame = new Ft12FrameDecode + { + Format = Ft12FrameFormat.SingleCharacter, + RawBytes = new byte[] { 0xE5 }, + IsChecksumValid = true + } + }; + var presentation = Iec101TraceNarrator.Describe(item); + Assert.Contains("E5 SHORT RESPONSE", presentation.Title); + Assert.Contains("matched link request", presentation.Meaning); + } +}