Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/IEC101_LIVE_TRACE_ASE2000_PARITY_20261029.md
Original file line number Diff line number Diff line change
@@ -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.
115 changes: 62 additions & 53 deletions src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -573,6 +584,30 @@ private void AddProtocolTraceRow(EvidenceRow row)
}
}

/// <summary>
/// A new connection must never inherit a stale Hold/selection state from a
/// previous session. Trace remains live without forcing navigation to another tab.
/// </summary>
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
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ private async Task RunPassiveRecordingAsync()
_passiveMonitorSession = session;
_passiveCaptureResult = null;
ClearSessionView(clearLog: true);
PrepareLiveTraceForConnect();
_passiveRawRows.Clear();
_passiveFrameSequence = 0;
_passiveUndecodableShown = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ private async Task OpenPassiveRawJournalAsync(string manifestPath)
try
{
ClearSessionView(clearLog: true);
PrepareLiveTraceForConnect();
_passiveFrameSequence = 0;
_passiveTransactionNotes.Clear();
_passiveFindingLastShown.Clear();
Expand Down
1 change: 1 addition & 0 deletions src/ARIEC60870.Desktop/Features/MainWindow.Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ private async void Start_Click(object sender, RoutedEventArgs e)
}

ClearSessionView(clearLog: false);
PrepareLiveTraceForConnect();
SeedValueViewerFromIoaProfile(settings.ProtocolMode);
_stopRequested = false;
_operatorDisconnectInProgress = false;
Expand Down
2 changes: 2 additions & 0 deletions src/ARIEC60870.Desktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ public partial class MainWindow : Window


private readonly ConcurrentQueue<Iec103MasterEvidenceEvent> _pendingEvidence = new();
// One early dispatcher flush per Connect; normal traffic uses the bounded UI timer.
private int _initialLiveTraceFlushPending;
private readonly ConcurrentQueue<Iec103MasterFinding> _pendingFindings = new();
private readonly Queue<string> _sessionLogLines = new();
private readonly DispatcherTimer _uiFlushTimer;
Expand Down
13 changes: 10 additions & 3 deletions src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
119 changes: 119 additions & 0 deletions src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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.");
Comment on lines +111 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate frame integrity before narrating link responses

For checksum-invalid IEC-101 frames that still have a decoded control byte, every path inside if (link is not null) returns before this integrity check. Both Ft12Parser and the passive monitor populate LinkControl for such frames, so a corrupted FC0 or FC9 is now displayed as an ordinary LINK ACK or DATA NOT AVAILABLE response, replacing the previous quality warning in ProtocolTraceMeaning. This hides relevant line-fault evidence during passive capture and replay. Check checksum and format validity before interpreting link functions, or explicitly retain the integrity warning alongside the tentative interpretation.

Useful? React with 👍 / 👎.


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";
}
Loading
Loading