From be047c0917b39892ca0ee472e0f4c5215c110606 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 29 Oct 2026 05:05:59 +0700 Subject: [PATCH 1/2] fix(wpf): crisp ClearType Trace and byte-accurate IEC-101 Inspector with linked raw field groups --- ...01_WPF_TEXT_AND_BYTE_INSPECTOR_20261029.md | 17 ++ .../Features/MainWindow.FrameInspector.cs | 62 +++++--- src/ARIEC60870.Desktop/MainWindow.xaml | 94 +++++------ .../ViewModels/Iec101FrameByteInspector.cs | 150 ++++++++++++++++++ .../Iec101FrameByteInspectorTests.cs | 142 +++++++++++++++++ 5 files changed, 388 insertions(+), 77 deletions(-) create mode 100644 docs/IEC101_WPF_TEXT_AND_BYTE_INSPECTOR_20261029.md create mode 100644 src/ARIEC60870.Desktop/ViewModels/Iec101FrameByteInspector.cs create mode 100644 tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs diff --git a/docs/IEC101_WPF_TEXT_AND_BYTE_INSPECTOR_20261029.md b/docs/IEC101_WPF_TEXT_AND_BYTE_INSPECTOR_20261029.md new file mode 100644 index 0000000..d13f05f --- /dev/null +++ b/docs/IEC101_WPF_TEXT_AND_BYTE_INSPECTOR_20261029.md @@ -0,0 +1,17 @@ +# WPF Trace readability + byte-true IEC-101 Inspector (2026-10-29) + +## Screenshot reproduction +At 1526x955, live Trace labels and HEX looked jagged/faint. Existing XAML directly overrode the theme's ClearType/Ideal text default with `TextFormattingMode="Display"` and ~10.5 DIP monospaced font across explanatory prose; Inspector used tiny two-column labels, noisy generic prose and fake raw chips (e.g. `L=105,CA=1,IOA=...` instead of hex). + +## Fix +- Trace title/meaning rendered in normal AppFont (Segoe UI/Aptos), 12.8/12.2 DIP, 18-DIP lines, darker ink and **Ideal + ClearType**, preserving light ListBox virtualized 1200-row bounded display; raw HEX uses Cascadia Mono/Consolas 11.6 DIP with Ideal + ClearType and no shadow, animation or per-frame additional UI. +- Inspector uses neutral white background; larger 11.9-12.2 DIP type and simple compact field + HEX + decoded value layout, not a large lecture-style two-column table. +- New **pure `Iec101FrameByteInspector.Explain`**, invoked only when a frame is selected, maps actual FT1.2 byte offsets and already-decoded ASDU information-object offsets into linked field/HEX groups; WPF receives one identical list as both HEX highlights and field explanations. Shows Start/L, Control (PRM/FCB/FCV or ACD/DFC), Link Address, Type ID, VSQ, COT, CA, *each* IOA, value/quality/CP56 time and checksum/integrity. It never invents an address string as HEX, and never reparses the original serial telegram. +- Each chip/field has a stable shared key, including object-N/value-N for multiple IOAs. Default focus is first value if decoded, else control. Typed check and raw byte length guard prevents out-of-bounds extraction. Unknown fields explicitly remain unknown; invalid checksum/length cannot become Good. +- Copy decoded frame uses the same field map as Inspector, not generic prose. +- IEC-103/104 Inspector branches remain unchanged; no changes to serial/TCP I/O, polling, FCB, passive RX-only or backpressure semantics. + +## Performance / FAT +Layout/font-only WPF changes; decoder usage restricted to selected Inspector frame. No new timers, repeated UI updates, polling, font dependencies, animations or bitmap buffers. 1200-row virtualization and 75ms batching unchanged. + +**Field FAT required**: test Windows display scaling 100%, 125%, 150% with COM 1200 bps and same SICAM setup, immediate Trace first paint, long streaming readability/no stutter; select 1-octet and 2-octet address frames, multi-IOA, FC9/ACD, Type30/CP56, invalid checksums; every HEX chip must match exact raw slice and update linked field highlight. Test screenshot legibility and compare ASE2000. CI is not visual proof of hardware rendered text. diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs index 85edf6e..3ec0a08 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs @@ -82,8 +82,12 @@ private void ApplySelectedEvidenceRowToInspector(object? selectedItem) SelectedRawText.Text = row.RawHex; _selectedFrameExplanation = explanation; SelectedLineSummaryText.Text = "Hover or click a protocol group. The panel stays stable; linked raw/meaning groups are highlighted without rewriting the inspector."; - SelectedLineDirectionText.Text = BuildLineMonitorTitle(row); - SelectedLineSummaryText.Text = BuildLineMonitorSummary(row); + SelectedLineDirectionText.Text = row.ProtocolMode == "101" + ? row.ProtocolTraceTitle + : BuildLineMonitorTitle(row); + SelectedLineSummaryText.Text = row.ProtocolMode == "101" + ? row.ProtocolTraceMeaning.Split('\n')[0] + : BuildLineMonitorSummary(row); UpdateFrameInterpreterTone(row); RebuildProtocolMap(row); ActivateDefaultProtocolMapGroup(row); @@ -102,8 +106,9 @@ private void ActivateDefaultProtocolMapGroup(EvidenceRow row) { "104" when row.ApciFormat == "I" && row.IoAddress != "-" => "object", "104" => "apci", - "101" when row.IoAddress != "-" => "object", - "101" when row.TypeId != "-" => "asdu", + "101" when row.Source.AsduDetails?.Objects.Count > 0 => "value-1", + "101" when row.Source.AsduDetails is not null => "asdu-type", + "101" => "control", "103" when row.FunInf != "-" => "asdu", _ => "raw" }; @@ -121,6 +126,8 @@ private static string DescribeProtocolMapKey(string key) "asdu" => "ASDU header selected", "cot" => "COT and flags selected", "object" => "object address selected", + _ when key.StartsWith("object-", StringComparison.OrdinalIgnoreCase) => "IOA bytes selected", + _ when key.StartsWith("value-", StringComparison.OrdinalIgnoreCase) => "value bytes selected", "payload" => "payload selected", "value" => "value selected", "check" => "integrity selected", @@ -138,20 +145,10 @@ private void UpdateFrameInterpreterTone(EvidenceRow? row) } var tone = row?.TrafficTone ?? string.Empty; - var background = tone switch - { - "Tx" => Color.FromRgb(245, 250, 255), - "Rx" => Color.FromRgb(244, 255, 249), - "Error" => Color.FromRgb(255, 245, 245), - _ => Color.FromRgb(248, 251, 255) - }; - var border = tone switch - { - "Tx" => Color.FromRgb(191, 219, 254), - "Rx" => Color.FromRgb(187, 247, 208), - "Error" => Color.FromRgb(254, 202, 202), - _ => Color.FromRgb(226, 232, 240) - }; + // Neutral inspector surface: meaning is conveyed by exact byte groups, + // not a full-height green/blue panel whose color competes with the text. + var background = tone == "Error" ? Color.FromRgb(255, 249, 249) : Color.FromRgb(255, 255, 255); + var border = tone == "Error" ? Color.FromRgb(254, 202, 202) : Color.FromRgb(226, 232, 240); FrameInterpreterPanel.Background = new SolidColorBrush(background); FrameInterpreterPanel.BorderBrush = new SolidColorBrush(border); @@ -250,6 +247,25 @@ private void RebuildProtocolMap(EvidenceRow row) SelectedProtocolMapLines.Clear(); SelectedHexSegments.Clear(); + // IEC-101: one exact offset/range SSOT for BOTH decoded field rows and + // linked raw HEX groups. No made-up "L=...,CA=..." as a HEX segment. + if (row.ProtocolMode == "101") + { + var fields = Iec101FrameByteInspector.Explain(row.Source); + if (fields.Count > 0) + { + foreach (var field in fields) + { + SelectedProtocolMapLines.Add(new ProtocolMapLine(field.Key, + field.Title, field.Meaning, field.Hex)); + if (field.Count > 0) + SelectedHexSegments.Add(new HexSegment(field.Key, + field.Hex, field.Title, field.Meaning)); + } + return; + } + } + foreach (var line in BuildProtocolMapLines(row)) { SelectedProtocolMapLines.Add(line); @@ -672,7 +688,15 @@ private void CopySelectedFrameDecode_Click(object sender, RoutedEventArgs e) var builder = new StringBuilder(); builder.AppendLine($"Frame #{_selectedFrameRow.Sequence} {BuildLineMonitorTitle(_selectedFrameRow)}"); - builder.AppendLine(BuildCompactFrameExplanation(_selectedFrameRow)); + if (_selectedFrameRow.ProtocolMode == "101") + { + foreach (var field in Iec101FrameByteInspector.Explain(_selectedFrameRow.Source)) + builder.AppendLine($"{field.Hex} → {field.Title}: {field.Meaning}"); + } + else + { + builder.AppendLine(BuildCompactFrameExplanation(_selectedFrameRow)); + } builder.AppendLine(); builder.AppendLine("Raw: " + _selectedFrameRow.RawHex); Clipboard.SetText(builder.ToString()); diff --git a/src/ARIEC60870.Desktop/MainWindow.xaml b/src/ARIEC60870.Desktop/MainWindow.xaml index 7a483ec..20c7685 100644 --- a/src/ARIEC60870.Desktop/MainWindow.xaml +++ b/src/ARIEC60870.Desktop/MainWindow.xaml @@ -486,7 +486,10 @@ PreviewMouseMove="FrameTraceGrid_PreviewMouseMove" PreviewMouseLeftButtonUp="FrameTraceGrid_PreviewMouseLeftButtonUp" PreviewMouseRightButtonDown="FrameTraceGrid_PreviewMouseRightButtonDown" - FontFamily="Sometype Mono, Cascadia Mono, Consolas"> + FontFamily="{StaticResource AppFont}" + TextOptions.TextFormattingMode="Ideal" + TextOptions.TextRenderingMode="ClearType" + TextOptions.TextHintingMode="Fixed"> - - - - - - - - - + + + + + + + diff --git a/src/ARIEC60870.Desktop/ViewModels/Iec101FrameByteInspector.cs b/src/ARIEC60870.Desktop/ViewModels/Iec101FrameByteInspector.cs new file mode 100644 index 0000000..a506faf --- /dev/null +++ b/src/ARIEC60870.Desktop/ViewModels/Iec101FrameByteInspector.cs @@ -0,0 +1,150 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using ARIEC60870.Core.Model; +using ARIEC60870.Master.Model; +using ARIEC60870.Master.Protocol.Iec10x; + +namespace ARIEC60870.Desktop.ViewModels; + +/// +/// Exact raw-byte ranges linked to typed IEC-101 interpretation. Invoked once +/// when a frame is selected, never in the receive/polling hot path. +/// Offsets are raw FT1.2 offsets; object offsets from decoder are ASDU-relative. +/// Invalid/truncated byte groups are never silently presented as healthy. +/// +public static class Iec101FrameByteInspector +{ + public sealed record Field(string Key, string Title, string Hex, string Meaning, int Offset, int Count); + + public static IReadOnlyList Explain(Iec103MasterEvidenceEvent evidence) + { + if (evidence.ProtocolMode != Iec60870ProtocolMode.Iec101 || + evidence.Frame is not { RawBytes.Count: > 0 } frame) + return Array.Empty(); + + var raw = frame.RawBytes; + var fields = new List(14); + void Add(string key, string title, int start, int count, string meaning) + { + if (count <= 0 || start < 0 || start > raw.Count || count > raw.Count - start) return; + var hex = string.Join(" ", raw.Skip(start).Take(count).Select(x => x.ToString("X2", CultureInfo.InvariantCulture))); + fields.Add(new Field(key, title, hex, meaning, start, count)); + } + + if (frame.Format == Ft12FrameFormat.SingleCharacter && raw.Count == 1) + { + Add("envelope", "Short response", 0, 1, raw[0] == 0xE5 + ? "E5 · Link ACK; match the request. Not proof of a command operation." + : $"0x{raw[0]:X2} · Check device-specific reply meaning."); + return fields; + } + + if (frame.Format == Ft12FrameFormat.FixedLength && raw.Count >= 4) + { + Add("envelope", "Start", 0, 1, "10 · Fixed-length link frame"); + Add("control", "Control", 1, 1, DescribeLink(frame.LinkControl)); + var addressSize = Math.Clamp(frame.LinkAddressSize, 0, 2); + if (addressSize > 0) + Add("address", "Link address", 2, addressSize, $"RTU {frame.LinkAddress?.ToString(CultureInfo.InvariantCulture) ?? "?"}"); + Add("check", "Integrity", raw.Count - 2, 2, DescribeIntegrity(frame)); + return fields; + } + + if (frame.Format != Ft12FrameFormat.VariableLength || raw.Count < 8) + { + Add("raw", "Unrecognized frame", 0, raw.Count, + "Frame structure is incomplete or invalid; check raw bytes."); + return fields; + } + + var linkSize = Math.Clamp(frame.LinkAddressSize, 0, 2); + var asduStart = 5 + linkSize; + Add("envelope", "FT1.2 header", 0, 4, + $"Start 68 · L={frame.DeclaredLength?.ToString(CultureInfo.InvariantCulture) ?? "?"} bytes" + + (frame.IsLengthValid ? "" : " · LENGTH INVALID")); + Add("control", "Link control", 4, 1, DescribeLink(frame.LinkControl)); + if (linkSize > 0) + Add("address", "Link address", 5, linkSize, + $"RTU {frame.LinkAddress?.ToString(CultureInfo.InvariantCulture) ?? "?"}"); + + var asdu = evidence.AsduDetails; + if (asdu is not null && asduStart + 2 <= raw.Count - 2) + { + Add("asdu-type", "Type ID", asduStart, 1, $"{asdu.TypeId} · {asdu.TypeName}"); + Add("vsq", "VSQ", asduStart + 1, 1, + $"Count {asdu.ObjectCount} · SQ={(asdu.IsSequence ? 1 : 0)}"); + var cotSize = asdu.CauseDetails?.OriginatorAddress.HasValue == true ? 2 : 1; + var headerSize = asdu.Objects.FirstOrDefault(o => o.InformationObjectOffset >= 0)?.InformationObjectOffset; + if (headerSize is null) + headerSize = 2 + cotSize + (raw.Count > asduStart + 4 ? 2 : 1); + var caSize = headerSize.Value - 2 - cotSize; + if (caSize is < 1 or > 2) + caSize = 0; // Unknown header size: don't invent offsets. + Add("cot", "Cause (COT)", asduStart + 2, cotSize, + $"{asdu.CauseOfTransmission} · {asdu.CauseName}" + + (asdu.IsNegativeConfirm ? " · NEGATIVE" : "") + + (asdu.IsTest ? " · TEST" : "") + + (cotSize == 2 ? $" · Origin {asdu.OriginatorAddress}" : "")); + if (caSize > 0) + Add("ca", "Common address", asduStart + 2 + cotSize, caSize, + $"CA {asdu.CommonAddress}"); + + foreach (var obj in asdu.Objects) + { + var key = $"object-{obj.Index}"; + if (obj.InformationObjectOffset >= 0 && + obj.InformationElementOffset > obj.InformationObjectOffset) + Add(key, $"IOA #{obj.Index}", asduStart + obj.InformationObjectOffset, + obj.InformationElementOffset - obj.InformationObjectOffset, + $"IOA {obj.InformationObjectAddress}"); + var meaning = $"{(asdu.TypeId is 1 or 2 or 3 or 4 or 30 or 31 ? "Digital" : "Value")} " + + $"IOA {obj.InformationObjectAddress} = {obj.ShortValue}"; + if (obj.QualityDetails is { } quality) + meaning += $" · {(quality.Invalid ? "INVALID" : quality.DisplayText)}"; + else if (!string.IsNullOrWhiteSpace(obj.QualityText)) + meaning += $" · {obj.QualityText}"; + if (!string.IsNullOrWhiteSpace(obj.TimestampText)) + meaning += $" · {obj.TimestampText}"; + if (obj.Issues.Count > 0) + meaning += " · Decode warning"; + Add($"value-{obj.Index}", $"Value #{obj.Index}", + asduStart + obj.InformationElementOffset, obj.RawBytes.Count, meaning); + } + + if (asdu.Objects.Count != asdu.ObjectCount || asdu.Issues.Count > 0) + fields.Add(new Field("issue", "Decode warning", "-", string.Join("; ", asdu.Issues), -1, 0)); + } + else if (raw.Count > asduStart + 2) + { + Add("asdu", "ASDU bytes", asduStart, raw.Count - asduStart - 2, + "ASDU not decoded; verify Type/COT/CA/IOA sizes."); + } + + Add("check", "Integrity", raw.Count - 2, 2, DescribeIntegrity(frame)); + return fields; + } + + private static string DescribeLink(LinkControlInfo? link) + { + if (link is null) return "Control not decoded"; + if (link.Prm) + return $"Master · FC={link.FunctionCode} ({link.FunctionName}) · PRM=1 FCB={Bit(link.Fcb)} FCV={Bit(link.Fcv)}"; + var result = $"RTU · FC={link.FunctionCode} ({link.FunctionName}) · PRM=0 ACD={Bit(link.Acd)} DFC={Bit(link.Dfc)}"; + if (link.FunctionCode == 9) result += " · No requested data"; + if (link.Acd == true) result += " · Class 1 pending"; + if (link.Dfc == true) result += " · Data flow blocked"; + return result; + } + + private static string DescribeIntegrity(Ft12FrameDecode frame) + { + var actual = frame.Checksum.HasValue ? $"CS 0x{frame.Checksum.Value:X2}" : "CS ?"; + var calculated = frame.CalculatedChecksum.HasValue ? $" (expected 0x{frame.CalculatedChecksum.Value:X2})" : ""; + return $"{actual}{calculated} · checksum {(frame.IsChecksumValid ? "OK" : "BAD")} · " + + $"length {(frame.IsLengthValid ? "OK" : "BAD")} · end 16"; + } + + private static string Bit(bool? bit) => bit.HasValue ? bit.Value ? "1" : "0" : "?"; +} diff --git a/tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs b/tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs new file mode 100644 index 0000000..43abbb3 --- /dev/null +++ b/tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs @@ -0,0 +1,142 @@ +// Copyright 2026 Ari Sulistiono +// 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; +using Xunit; + +namespace ARIEC60870.Desktop.Tests; + +public sealed class Iec101FrameByteInspectorTests +{ + [Fact] + public void Fc11RequestMapsEachOnWireByteToItsActualMeaning() + { + var raw = Convert.FromHexString("105B6900C416"); + // Two-octet Link Address: 69 00 (the SICAM configuration in the screenshot). + var frame = new Ft12Parser(2).Decode(raw); + var fields = Iec101FrameByteInspector.Explain(new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + Frame = frame + }); + + Assert.Equal("10", fields.Single(x => x.Key == "envelope").Hex); + Assert.Equal("5B", fields.Single(x => x.Key == "control").Hex); + Assert.Contains("FC=11", fields.Single(x => x.Key == "control").Meaning); + Assert.Contains("FCV=1", fields.Single(x => x.Key == "control").Meaning); + Assert.Equal("69 00", fields.Single(x => x.Key == "address").Hex); + Assert.Contains("RTU 105", fields.Single(x => x.Key == "address").Meaning); + Assert.Equal("C4 16", fields.Single(x => x.Key == "check").Hex); + Assert.Contains("checksum OK", fields.Single(x => x.Key == "check").Meaning); + Assert.DoesNotContain(fields, x => x.Key == "asdu-type"); + } + + [Fact] + public void Fc9NoDataDoesNotMisreportClass1PendingAsReceivedData() + { + var frame = new Ft12Parser(1).Decode(Convert.FromHexString("1029699216")); + var control = Iec101FrameByteInspector.Explain(new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + Frame = frame + }).Single(x => x.Key == "control"); + + Assert.Equal("29", control.Hex); + Assert.Contains("FC=9", control.Meaning); + Assert.Contains("ACD=1", control.Meaning); + Assert.Contains("Class 1 pending", control.Meaning); + Assert.Contains("No requested data", control.Meaning); + } + + [Fact] + public void Type30ShowsExactOffsetsAndValuesWithNoFakeHexAddresses() + { + var asduBytes = new byte[] { + 0x1E, 0x01, 0x14, 0x00, 0x01, 0x00, + 0x6C, 0x00, 0x80, 0x01, + 0x2C, 0x6F, 0x27, 0x14, 0x1C, 0x0A, 0x1A + }; + var l = asduBytes.Length + 2; + var raw = new byte[l + 6]; + raw[0] = 0x68; + raw[1] = (byte)l; + raw[2] = (byte)l; + raw[3] = 0x68; + raw[4] = 0x28; + raw[5] = 0x69; + asduBytes.CopyTo(raw, 6); + raw[l + 4] = (byte)raw.Skip(4).Take(l).Sum(b => b); + raw[l + 5] = 0x16; + + var frame = new Ft12Parser(1).Decode(raw); + var asdu = new Iec10xAsduDecoder(2, 2, 3).Decode(asduBytes); + Assert.True(frame.IsChecksumValid); + Assert.Empty(asdu.Issues); + var fields = Iec101FrameByteInspector.Explain(new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + Frame = frame, + AsduDetails = asdu + }); + Assert.Equal("68 13 13 68", fields.Single(x => x.Key == "envelope").Hex); + Assert.Equal("28", fields.Single(x => x.Key == "control").Hex); + Assert.Equal("1E", fields.Single(x => x.Key == "asdu-type").Hex); + Assert.Equal("01", fields.Single(x => x.Key == "vsq").Hex); + Assert.Equal("14 00", fields.Single(x => x.Key == "cot").Hex); + Assert.Equal("01 00", fields.Single(x => x.Key == "ca").Hex); + Assert.Equal("6C 00 80", fields.Single(x => x.Key == "object-1").Hex); + var value = fields.Single(x => x.Key == "value-1"); + Assert.Equal("01 2C 6F 27 14 1C 0A 1A", value.Hex); + Assert.Contains("IOA 8388716 = ON", value.Meaning); + Assert.Contains("2026-10-28 20:39:28.460", value.Meaning); + Assert.All(fields.Where(x => x.Count > 0), f => + Assert.Equal(string.Join(" ", raw.Skip(f.Offset).Take(f.Count).Select(b => b.ToString("X2"))), f.Hex)); + Assert.DoesNotContain(fields, x => x.Hex.Contains("CA=", StringComparison.Ordinal)); + } + + [Fact] + public void MultiObjectAsduPreservesIndividualRawByteGroups() + { + var asduBytes = new byte[] { + 0x01, 0x02, 0x03, 0x00, 0x01, 0x00, + 0x0A, 0x00, 0x00, 0x01, + 0x0B, 0x00, 0x00, 0x00 + }; + var l = asduBytes.Length + 2; + var raw = new byte[l + 6]; + raw[0] = 0x68; raw[1] = (byte)l; raw[2] = (byte)l; raw[3] = 0x68; + raw[4] = 0x08; raw[5] = 0x69; + asduBytes.CopyTo(raw, 6); + raw[l + 4] = (byte)raw.Skip(4).Take(l).Sum(b => b); + raw[l + 5] = 0x16; + + var fields = Iec101FrameByteInspector.Explain(new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101, + Frame = new Ft12Parser(1).Decode(raw), + AsduDetails = new Iec10xAsduDecoder(2, 2, 3).Decode(asduBytes) + }); + Assert.Equal("0A 00 00", fields.Single(x => x.Key == "object-1").Hex); + Assert.Equal("01", fields.Single(x => x.Key == "value-1").Hex); + Assert.Equal("0B 00 00", fields.Single(x => x.Key == "object-2").Hex); + Assert.Equal("00", fields.Single(x => x.Key == "value-2").Hex); + } + + [Fact] + public void NoFabricatedFieldsForOtherProtocolsOrNoRawFrame() + { + Assert.Empty(Iec101FrameByteInspector.Explain(new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec104, + Frame = new Ft12Parser(1).Decode(Convert.FromHexString("105B6900C416")) + })); + Assert.Empty(Iec101FrameByteInspector.Explain(new Iec103MasterEvidenceEvent + { + ProtocolMode = Iec60870ProtocolMode.Iec101 + })); + } +} From 210b9a4488340a8806791b4cee0aa1768a417cad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 29 Oct 2026 05:10:09 +0700 Subject: [PATCH 2/2] test(inspector): preserve typed SP=ON digital-value label in byte mapping assertion --- tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs b/tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs index 43abbb3..98a6b4a 100644 --- a/tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs +++ b/tests/ARIEC60870.Desktop.Tests/Iec101FrameByteInspectorTests.cs @@ -91,7 +91,7 @@ public void Type30ShowsExactOffsetsAndValuesWithNoFakeHexAddresses() Assert.Equal("6C 00 80", fields.Single(x => x.Key == "object-1").Hex); var value = fields.Single(x => x.Key == "value-1"); Assert.Equal("01 2C 6F 27 14 1C 0A 1A", value.Hex); - Assert.Contains("IOA 8388716 = ON", value.Meaning); + Assert.Contains("IOA 8388716 = SP=ON", value.Meaning); Assert.Contains("2026-10-28 20:39:28.460", value.Meaning); Assert.All(fields.Where(x => x.Count > 0), f => Assert.Equal(string.Join(" ", raw.Skip(f.Offset).Take(f.Count).Select(b => b.ToString("X2"))), f.Hex));