Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dc32213
feat(iec10x): add typed COT, quality and command response semantics (…
masarray Oct 28, 2026
0d8086b
feat(iec10x): preserve command COT, typed quality and ASDU offsets in…
masarray Oct 28, 2026
3492c4b
feat(iec10x): preserve command COT, typed quality and ASDU offsets in…
masarray Oct 28, 2026
c7359ca
feat(iec10x): project typed command/response evidence from Master and…
masarray Oct 28, 2026
70b8cd5
feat(iec10x): project typed command/response evidence from Master and…
masarray Oct 28, 2026
3a572a9
feat(iec10x): project typed command/response evidence from Master and…
masarray Oct 28, 2026
a929b2a
feat(iec10x): project typed command/response evidence from Master and…
masarray Oct 28, 2026
4fa6f53
feat(trace): typed command response and COT detail, normalize HEX acr…
masarray Oct 28, 2026
a9cc199
fix(iec101): count secondary FC9 directly instead of NO DATA text (#39)
masarray Oct 28, 2026
cff684f
test(iec10x): synthetic command ACTCON/negative, COT flags, quality, …
masarray Oct 28, 2026
2115365
test(iec10x): use explicit nullable comparisons for qualifier/quality…
masarray Oct 28, 2026
42f7c5c
docs: record M1 command evidence contract, limitations and next gates…
masarray Oct 28, 2026
5691f58
feat(iec10x): include bitstring command Type51/64 and time-tagged res…
masarray Oct 28, 2026
ef7f766
feat(iec10x): include bitstring command Type51/64 and time-tagged res…
masarray Oct 28, 2026
1676463
refactor(iec10x): remove superseded duplicate textual quality decoder…
masarray Oct 28, 2026
1fc06de
fix(iec10x): comma in C_BO command semantic switch discovered by Wind…
masarray Oct 28, 2026
9d4abac
fix(iec10x): do not label command ASDUs without QDS/DIQ as Quality=Go…
masarray Oct 28, 2026
e6e56c9
test(iec10x): commands without quality octet must not display Good (#39)
masarray Oct 28, 2026
6edbeca
fix(trace): use IEC101 link width 0/1/2 for frame control, ASDU and c…
masarray Oct 28, 2026
b176a45
test(iec10x): cover QOS select and C_BO time-tagged command response …
masarray Oct 28, 2026
5a1b106
test: compare nullable raw QOS bytes precisely (#39)
masarray Oct 28, 2026
4156646
feat(iec10x): finish M1 typed per-object provenance across active dua…
masarray Oct 28, 2026
7912e60
test(iec101): isolate byte-exact capture proof from backpressure; pre…
masarray Oct 28, 2026
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
45 changes: 45 additions & 0 deletions docs/UNIFIED_PROTOCOL_COMMAND_FORENSICS_M1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# M1: IEC-101/104 Command and Response Forensic Semantics

Related: [Issue #39](https://github.com/masarray/ARIEC60870/issues/39)

## Field requirement
Command actions and the RTU's answer must be decipherable with the same rigor as a spontaneous event: COT numeric/flags, raw qualifier and its bits, Type, CA, IOA, current link/APCI context, source bytes and exact ASDU information-element offsets. Never report successful switchgear motion merely because a link ACK or COT7 ACTCON was observed.

## M1 implementation (this branch)
- Add immutable semantic facts `Iec10xCauseDetails`, `Iec10xQualityDetails`, and `Iec10xCommandDetails` owned by the IEC-10x decoder layer (not re-parsed from UI strings).
- Preserve numeric raw COT and flags P/N, TEST, OA (including zero when second COT octet configured). Explicit phase/outcome descriptions for COT5/6/7/8/9/10/11/12/44..47.
- Decode per-information-object quality (SIQ, DIQ, QDS and BCR); overflow only exists for QDS. BCR carry is not overflow. All inactive quality bits and N/A remain available in structured properties.
- Preserve ASDU-relative information-object and element offsets. For SQ1 subsequent objects, the implicit IOA has no physical on-wire IOA offset (represented by -1).
- Type-aware command qualifier interpretation: single/double/regulating, select vs execute, QOS/QU, QOI, QCC, QRP, read/clock/test baseline. Decoder never infers successful primary-process actuation from protocol confirmation.
- Carry command/COT/quality information as typed evidence through IEC-101 Active Master, IEC-104 client, and IEC-101 Passive Monitor, and include all details in selected Frame Inspector.
- Fix compact HEX from Passive Monitor being treated as a single byte in Frame Inspector; fix IEC-101 FC9 NO DATA count by numeric function code.

## M1 deliberate boundaries
This is the **first implementation milestone**, not the finished unified forensic system.

Not yet implemented here:
- Multi-stage command transaction correlation (SBO SELECT → ACTCON → EXECUTE → ACTCON → ACTTERM and optional returned state), with ambiguity, missing/timeout and RTU processing timing caveats. Existing passive request/reply correlator remains in place.
- Structured full byte-range map and interactive bit overlays for every field, especially 0/2-byte link addressing; M1 exposes exact ASDU qualifier element offsets but the old UI grouping still has known fixed-offset limitations.
- Compact premium badge templates in the single Quality column on Values/Events; typed flags now exist as the source for that follow-up. **Do not add per-flag columns.**
- Event Log coverage correction for all first-observed/repeated spontaneous event objects; complete per-object provenance and new capture schema.
- Protocol-specific IEC-103 command data model; current milestone is IEC-101/104 shared ASDU.
- UI thread/performance baselines, Windows manual GUI, full hardware SICAM interoperability. Require CI and field verification later.

## Next milestones
M2: immutable canonical Frame/ASDU/Object/Transaction IDs, Active Master + Passive + Replay parity, event provenance and action lifecycle.
M3: one Quality badge cell, one COT cell, linked full-bit/field inspector + source-highlight, report/.ariec parity.
M4: Windows GUI smoke, sustained replay/CPU/memory benchmarks and reproducible hardware test build.

## Public synthetic tests (no private captures)
Test C_SC SELECT, ACTCON positive, C_DC EXECUTE negative P/N, TEST and COT47 error, ACTTERM and QOI, DIQ overflow N/A, QDS OVF+IV, BCR carry, SQ1 exact byte offsets. Tests are in `tests/ARIEC60870.Master.Tests/Iec10xCommandForensicsTests.cs`.

## Engineering guardrails
Do not alter scheduling/FCB state-machine behavior in M1, and never add TX code to Passive Monitor. Parser work must be pure, bounded and independent of WPF. Never infer actual switchgear movement without later process-state evidence. Raw capture evidence is private and must not be committed to public fixtures.

## M1 continuation — typed per-object source and timestamp correctness

- Retain a shared decoded ASDU and selected information-object reference in every IEC-101/104 Active Master, IEC-101 redundant and IEC-101 Passive event. Reuse one source record rather than parsing from WPF strings or copying decoded raw bytes per object.
- Secondary objects preserve their own typed COT, command, quality and exact ASDU-relative object/element byte offsets, even for SQ1 implicit IOAs. The inspector shows the selected object's metadata and a bounded first-page overview of an ASDU's objects.
- IEC-101 E5 in the inspector is contextual (link ACK versus Class 1/2 no-data), never proof of successful application command.
- CP56Time2a day-of-week high bits must not be treated as invalid-time. Clock/calendar bounds and minute IV are checked. CP24 hourless clock ranges are checked. Public regression fixtures cover valid DOW and invalid ranges.
- Passive multi-object projection encodes HEX once per frame instead of once per IOA. Full command transaction pairing, completed premium badge templates and report/capture SSOT remain M2/M3; no field-device FAT is implied.
90 changes: 76 additions & 14 deletions src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"ft12" => "FT1.2 selected",
"control" => "link control selected",
"asdu" => "ASDU header selected",
"cot" => "COT and flags selected",
"object" => "object address selected",
"payload" => "payload selected",
"value" => "value selected",
Expand Down Expand Up @@ -171,6 +172,28 @@
parts.Add($"Value: {row.SemanticState}.");
}

if (row.Source.CauseDetails is { } cot)
parts.Add("COT / RTU response: " + cot.Detail);
if (row.Source.CommandDetails is { } cmd)
parts.Add("Command / response: " + cmd.Detail);
if (row.Source.QualityDetails is { } quality)
parts.Add("Quality bits: " + quality.BitDetails);
if (row.Source.InformationObjectDetails is { } selectedObject)
parts.Add($"Object #{selectedObject.Index} IOA={selectedObject.InformationObjectAddress}; " +
$"ASDU IOA offset={(selectedObject.InformationObjectOffset < 0 ? "implicit SQ" : selectedObject.InformationObjectOffset.ToString())}; " +
$"element byte offset={selectedObject.InformationElementOffset}; length={selectedObject.RawBytes.Count}.");
if (row.Source.AsduDetails is { Objects.Count: > 1 } parsed &&
row.Source.InformationObjectDetails?.Index == 1)
{
// Lazy, bounded selected-frame inspection. The complete decoded object array
// stays available as one shared source, without adding UI grid columns.
foreach (var obj in parsed.Objects.Take(16))
parts.Add($"#{obj.Index} IOA={obj.InformationObjectAddress}: {obj.ShortValue}; " +
$"quality={obj.QualityDetails?.DisplayText ?? "N/A"}; element byte={obj.InformationElementOffset}.");
if (parsed.Objects.Count > 16)
parts.Add($"+{parsed.Objects.Count - 16} more objects retained in the decoded ASDU.");
}

parts.Add(row.ProtocolMode switch
{
"104" => $"Protocol: IEC-104 {row.Direction}, APCI={row.ApciFormat}, NS={row.SendSequence}, NR={row.ReceiveSequence}, Type ID={row.TypeIdName}, COT={row.CotDisplay}, CA={row.CommonAddress}, IOA={row.IoAddress}.",
Expand Down Expand Up @@ -262,6 +285,8 @@
{
yield return new ProtocolMapLine("asdu", "ASDU header", BuildAsduHeaderMeaning(row), string.Join(" ", bytes.Skip(6).Take(Math.Min(6, Math.Max(0, bytes.Length - 6)))));
yield return new ProtocolMapLine("object", "CA / IOA", BuildSignalAddressMeaning(row), row.ProtocolAddress);
if (row.Source.CommandDetails is { } cmd104)
yield return new ProtocolMapLine("command", "Command / RTU response", cmd104.Detail, CommandEvidence(row, bytes, 6));
Comment on lines +288 to +289
if (!string.IsNullOrWhiteSpace(row.SemanticState))
{
yield return new ProtocolMapLine("payload", "Information element", BuildPayloadMeaning(row), row.SemanticState);
Expand All @@ -273,9 +298,15 @@
if (row.ProtocolMode == "101" && bytes[0].Equals("68", StringComparison.OrdinalIgnoreCase) && bytes.Length >= 9)
{
yield return new ProtocolMapLine("envelope", "FT1.2 variable frame", "IEC-101 serial variable-length frame. The repeated length block and checksum protect the serial telegram.", string.Join(" ", bytes.Take(4)));
yield return new ProtocolMapLine("control", "Link control", BuildControlMeaning(row), BuildControlEvidence(row, string.Join(" ", bytes.Skip(4).Take(2))));
yield return new ProtocolMapLine("asdu", "ASDU header", BuildAsduHeaderMeaning(row), string.Join(" ", bytes.Skip(6).Take(Math.Min(6, Math.Max(0, bytes.Length - 8)))));
var linkSize = Math.Clamp(row.Source.Frame?.LinkAddressSize ?? 1, 0, 2);
var asduStart = 5 + linkSize;
yield return new ProtocolMapLine("control", "Link control and address", BuildControlMeaning(row), BuildControlEvidence(row, string.Join(" ", bytes.Skip(4).Take(1 + linkSize))));
yield return new ProtocolMapLine("asdu", "ASDU header", BuildAsduHeaderMeaning(row), string.Join(" ", bytes.Skip(asduStart).Take(Math.Min(6, Math.Max(0, bytes.Length - asduStart - 2)))));
if (row.Source.CauseDetails is { } cause)
yield return new ProtocolMapLine("cot", "COT flags", cause.Detail, string.Join(" ", bytes.Skip(asduStart + 2).Take(cause.OriginatorAddress.HasValue ? 2 : 1)));
yield return new ProtocolMapLine("object", "Information object address", BuildSignalAddressMeaning(row), row.ProtocolAddress);
if (row.Source.CommandDetails is { } cmd101)
yield return new ProtocolMapLine("command", "Command / RTU response", cmd101.Detail, CommandEvidence(row, bytes, 5 + (row.Source.Frame?.LinkAddressSize ?? 1)));
if (!string.IsNullOrWhiteSpace(row.SemanticState))
{
yield return new ProtocolMapLine("payload", "Information element", BuildPayloadMeaning(row), row.SemanticState);
Expand All @@ -286,17 +317,22 @@

if (bytes[0].Equals("E5", StringComparison.OrdinalIgnoreCase))
{
yield return new ProtocolMapLine("envelope", "Single char ACK", "IEC FT1.2 single-character acknowledgement. The relay accepted the previous link/action frame.", "E5");
var meaning = row.ProtocolMode == "101"
? "IEC-101 E5 is contextual: link acknowledgement or NO DATA after a pending Class 1/2 poll. " +
"Without an unambiguous matched request, the reply outcome remains unknown."
: "IEC-103 E5 single-character link acknowledgement; it does not prove an application command or physical operation succeeded.";
yield return new ProtocolMapLine("envelope", "E5 short response", meaning, "E5");
yield break;
}


if (bytes[0].Equals("10", StringComparison.OrdinalIgnoreCase) && bytes.Length >= 5)
{
yield return new ProtocolMapLine("envelope", "FT1.2 fixed frame", $"Short {row.ProtocolName} link frame. Used for reset, Class 1/Class 2 request, ACK, or NO DATA response.", string.Join(" ", bytes.Take(1)));
var linkSize = Math.Clamp(row.Source.Frame?.LinkAddressSize ?? 1, 0, 2);
yield return new ProtocolMapLine("control", "Control field", BuildControlMeaning(row), BuildControlEvidence(row, bytes.ElementAtOrDefault(1) ?? "-"));
yield return new ProtocolMapLine("address", "Link address", "Slave/link address on the serial IEC-60870 link.", bytes.ElementAtOrDefault(2) ?? "-");
yield return new ProtocolMapLine("check", "Integrity", "Checksum and stop byte. This proves what was actually transmitted on the wire.", string.Join(" ", bytes.Skip(3).Take(2)));
yield return new ProtocolMapLine("address", "Link address", "Slave/link address on the serial IEC-60870 link.", linkSize == 0 ? "N/A (0 byte)" : string.Join(" ", bytes.Skip(2).Take(linkSize)));
yield return new ProtocolMapLine("check", "Integrity", "Checksum and stop byte. This proves what was actually transmitted on the wire.", string.Join(" ", bytes.Skip(2 + linkSize).Take(2)));
yield break;
}

Expand Down Expand Up @@ -324,6 +360,14 @@
yield return new ProtocolMapLine("raw", $"Raw {row.ProtocolName} bytes", "The analyzer preserved this frame as raw evidence, but it could not classify it into the expected protocol structure.", string.Join(" ", bytes));
}

private static string CommandEvidence(EvidenceRow row, string[] bytes, int asduStart)
{
var cmd = row.Source.CommandDetails;
if (cmd?.QualifierAsduOffset is int offset && asduStart + offset < bytes.Length)
return bytes[asduStart + offset];
return cmd?.QualifierRaw is byte raw ? $"0x{raw:X2}" : "No qualifier byte";
}

private static string BuildControlEvidence(EvidenceRow row, string rawControl)
{
var flags = row.LinkFlags == "-" ? string.Empty : " · " + row.LinkFlags;
Expand All @@ -343,6 +387,10 @@

if (row.Direction == "RX")
{
if (row.Source.Frame?.LinkControl is { Prm: false, FunctionCode: 9 })
return "Secondary FC=9: NO REQUESTED DATA; not an application command confirmation.";
if (row.Source.Frame?.LinkControl is { Prm: false, FunctionCode: 0 })
return "Secondary FC=0: link-layer ACK, not proof that the physical command operated.";
if (row.Acd == "1")
{
return "Relay response indicates ACD=1, meaning Class 1 data is pending and the master may drain event data.";
Expand All @@ -368,7 +416,8 @@
return "No IEC-10x ASDU payload is present in this frame.";
}

return $"Type ID={row.TypeIdName}, VSQ={row.Vsq}, COT={row.CotDisplay}, CA={row.CommonAddress}. These fields define the telecontrol data class, cause, station/common address, and object addressing context.";
var detail = row.Source.CauseDetails?.Detail ?? $"COT={row.CotDisplay}";
return $"Type ID={row.TypeIdName}, VSQ={row.Vsq}, {detail} CA={row.CommonAddress}.";
}

if (row.AsduType == "-" && row.Cot == "-")
Expand Down Expand Up @@ -404,6 +453,8 @@
var time = string.IsNullOrWhiteSpace(row.RelayTime) || row.RelayTime == "-" ? "No field timestamp decoded." : $"Field timestamp: {row.RelayTime}.";
var typeText = row.ProtocolMode is "101" or "104" ? row.TypeIdName : row.AsduType;

if (row.Source.CommandDetails is { } command) return command.Detail;

if (typeText.Contains("Measur", StringComparison.OrdinalIgnoreCase))
{
return $"Measurement payload. Decoded value/state: {state}. Quality: {row.Quality}. {time}";
Expand All @@ -423,10 +474,18 @@

private static string[] SplitHexBytes(string rawHex)
{
return rawHex
.Split(new[] { ' ', '|', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Where(x => !string.IsNullOrWhiteSpace(x) && x != "-")
.ToArray();
// Master uses spaced HEX; Passive Monitor/archives may use compact HEX.
// This executes only upon selection, not in the receive loop.
var tokens = rawHex.Split(new[] { ' ', '|', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var bytes = new List<string>(tokens.Length);
foreach (var token in tokens)
{
if (token == "-") continue;
if (token.Length > 2 && (token.Length & 1) == 0 && token.All(Uri.IsHexDigit))
for (var i = 0; i < token.Length; i += 2) bytes.Add(token.Substring(i, 2));
else bytes.Add(token);
}
Comment on lines +481 to +487
return bytes.ToArray();
}

private static IEnumerable<HexSegment> BuildHexSegments(EvidenceRow row)
Expand Down Expand Up @@ -460,8 +519,10 @@
if (row.ProtocolMode == "101" && bytes[0].Equals("68", StringComparison.OrdinalIgnoreCase) && bytes.Length >= 9)
{
yield return new HexSegment("envelope", string.Join(" ", bytes.Take(4)), "FT1.2 variable frame", "IEC-101 variable-length serial frame envelope.");
yield return new HexSegment("control", string.Join(" ", bytes.Skip(4).Take(2)), "Control + link", BuildControlMeaning(row));
yield return new HexSegment("asdu", string.Join(" ", bytes.Skip(6).Take(Math.Min(6, Math.Max(0, bytes.Length - 8)))), "ASDU header", BuildAsduHeaderMeaning(row));
var linkSize = Math.Clamp(row.Source.Frame?.LinkAddressSize ?? 1, 0, 2);
var asduStart = 5 + linkSize;
yield return new HexSegment("control", string.Join(" ", bytes.Skip(4).Take(1 + linkSize)), "Control + link", BuildControlMeaning(row));
yield return new HexSegment("asdu", string.Join(" ", bytes.Skip(asduStart).Take(Math.Min(6, Math.Max(0, bytes.Length - asduStart - 2)))), "ASDU header", BuildAsduHeaderMeaning(row));
yield return new HexSegment("object", row.ProtocolAddress, "Information object address", BuildSignalAddressMeaning(row));
if (!string.IsNullOrWhiteSpace(row.SemanticState))
{
Expand All @@ -475,8 +536,9 @@
{
yield return new HexSegment("envelope", bytes[0], "FT1.2 fixed frame", "Fixed-length link frame envelope.");
yield return new HexSegment("control", bytes[1], "Control", BuildControlMeaning(row));
yield return new HexSegment("address", bytes[2], "Link address", "Relay/slave link address.");
yield return new HexSegment("check", string.Join(" ", bytes.Skip(3).Take(2)), "Integrity", "Checksum and end byte.");
var linkSize = Math.Clamp(row.Source.Frame?.LinkAddressSize ?? 1, 0, 2);
yield return new HexSegment("address", linkSize == 0 ? "N/A" : string.Join(" ", bytes.Skip(2).Take(linkSize)), "Link address", "Relay/slave link address.");
yield return new HexSegment("check", string.Join(" ", bytes.Skip(2 + linkSize).Take(2)), "Integrity", "Checksum and end byte.");
yield break;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1185,7 +1185,10 @@ private void UpdateLiveCounters(Iec103MasterEvidenceEvent item)
}
}

if (item.Summary.Contains("NO DATA", StringComparison.OrdinalIgnoreCase) || item.Detail.Contains("NO DATA", StringComparison.OrdinalIgnoreCase))
// Use numeric IEC-101 secondary FC9, not message wording, for the NO DATA counter.
if (item.ProtocolMode == Iec60870ProtocolMode.Iec101
? item.Frame?.LinkControl is { Prm: false, FunctionCode: 9 }
: item.Summary.Contains("NO DATA", StringComparison.OrdinalIgnoreCase) || item.Detail.Contains("NO DATA", StringComparison.OrdinalIgnoreCase))
{
_noDataCount++;
}
Expand Down
Loading
Loading