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
9 changes: 9 additions & 0 deletions docs/EVENT_LOG_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,12 @@ They should enter Relay Event Log only when:
- the value represents a state change compared with previous known state
- the frame is explicitly event-like/spontaneous
- user enables a debug option to log GI snapshots

## M2 — received spontaneous event vs state transition (2026-10-29)

- For valid RX IEC-101/104 digital ASDU, COT 3 (spontaneous) and 11/12 (return information) each represent an **observed protocol event**, even when the reported state repeats or was not known previously. The event reason identifies `first observation`, `repeat observation`, or `state change`; these are not synonymous.
- GI/cyclic digital snapshots enter SOE only with a known state transition. Repeated unchanged GI values must not create synthetic events.
- IEC-101/104 Values identity is protocol + physical channel + Common Address + IOA, **not IOA alone**. Mapping requires compatible CA/Type or an explicit wildcard; mismatch stays visible and diagnostic.
- GI completion tracks configured CA+IOA independently of physical channel; other CA must not satisfy expected coverage.
- Original RX tap stream/byte offset and object index remain available as a capture-local source reference. Derived references never imply verified raw integrity on their own.
- Refer to `docs/IEC10X_M2_PHYSICAL_IDENTITY_SOE.md` for limitations and tests.
36 changes: 36 additions & 0 deletions docs/IEC10X_M2_PHYSICAL_IDENTITY_SOE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# M2 — Physical point identity, received-event facts and evidence references

2026-10-29 · ARIEC60870 / IEC-101 & IEC-104. Builds on merged M1 `6688363`.

## Operator outcomes

- **Values**: the same IOA from Link A and Link B or different common addresses is no longer silently overwritten. Physical identity is `protocol / capture channel / CA / IOA`; the source channel appears compactly beside the IOA (no extra wide grid).
- **Signal mapping**: an exact CA+IOA+Type matching user point can supply its friendly name. A mismatch does **not** silently borrow an IOA-only label. Explicit wildcard CA/Type in a user profile is allowed. Actual unexpected CA remains visible unmapped for diagnosis.
- **GI completeness**: logical *expected profile* CA+IOA keys are tracked separately from physical Values keys. A response from another CA does not satisfy a configured station's GI coverage. Source-specific snapshots do not inflate one logical GI point into two.
- **Event Log**: an actually received, valid digital spontaneous/return-information object (COT 3, 11, 12) is a preserved observation even for the first occurrence or a repeated state. Reason distinguishes *first observation*, *repeat observation*, and *state change*. Cyclic/GI no-change digital snapshots are not converted into SOE events.
- **Provenance**: each event carries lightweight source-channel metadata and a session-local reference. IEC-101 passive source stream and original byte offset + information object index can be used for exact raw journal verification. An Active Master sequence number is not a physical byte offset. The journal remains read-only and verification must still occur before any raw record is claimed authenticated.
- No new polling, timers, busy loops, COM writes, or duplicated WPF decoder. No new grid columns or expensive per-frame formatting.

## Identity contract

`Iec10xObservationIdentity.ValueKey` is pure and bounded, using protocol, physical source, CA and IOA. No type in the key because time-tagged and untimed ASDU variants can update the same logical process value from the same physical source. The full Type ID and Quality remain on the evidence. Unknown CA is distinct from known CA=0. A channel name is normalized but not inferred from an IOA.

For active IEC-101/104 without a separately identified redundant channel, source is `SINGLE` within the active session. In IEC-101 A/B runtime channel names are explicit, and passive streams 0/1 map to Link A, streams 2/3 to Link B. No cross-session/global uniqueness is claimed without a persisted session/archive identifier.

## Regression gates

1. Identical IOA but different protocol, channel, CA or IOA must produce distinct keys; whitespace/case variants of the *same* channel normalize.
2. COT 3, 11, 12 received digital observations remain facts even without baseline/state transition; GI COT 20, outbound TX and command ASDUs must not be mislabeled as spontaneous.
3. A passive source reference retains the original stream, byte offset and object index; Active Master references carry sequence only.
4. Existing IEC-101/104 active, dual-link, passive, GI and event behavior must retain all established tests; Release Windows + protocol smoke + xUnit + CodeQL + hygiene must succeed on the same head before merge.
5. Real SICAM/ETERA and multi-day loading still require FAT. No sampled-value SV transport was added: that is ARStack61850, not ARIEC60870.

## Remaining M2/M3 scope

Full durable Frame→ASDU→Object ID persistence across `.ariec`, command SELECT/EXECUTE/ACTCON/ACTTERM pairing, command feedback proof, stable historical drill-down, and identical report fields are follow-ups. Do not describe this physical identity/SOE checkpoint as full forensics parity with IEC-Test.

## Final M2 UI guard

A profile-seeded SINGLE placeholder is removed when a source-qualified or wildcard-CA physical observation satisfies that exact configured point. The actual Link A/B records remain independent. A wrong-CA or wrong-Type observation cannot clear the placeholder or count toward expected GI coverage.

An actual RX GI/cyclic digital state transition (a known previous state and a new different state) may enter SOE, but an unchanged GI snapshot may not. A valid spontaneous RX event enters SOE even without that transition. TX events are never promoted to SOE as digital observations.
4 changes: 4 additions & 0 deletions src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ private static string BuildCompactFrameExplanation(EvidenceRow row)
parts.Add("Command / response: " + cmd.Detail);
if (row.Source.QualityDetails is { } quality)
parts.Add("Quality bits: " + quality.BitDetails);
if (row.Source.InformationObjectDetails is not null &&
!string.IsNullOrWhiteSpace(row.Source.EvidenceReference))
parts.Add("Evidence reference (session-local): " + row.Source.EvidenceReference +
". For passive captures, verify against the original journal before claiming raw integrity.");
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())}; " +
Expand Down
77 changes: 46 additions & 31 deletions src/ARIEC60870.Desktop/Features/MainWindow.MappingValueStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,17 @@ private void EditSignalList_Click(object sender, RoutedEventArgs e)
return null;
}

var exact = _ioaProfile.Resolve(item.CommonAddressNumber, item.InformationObjectAddress, item.TypeId);
if (exact is not null || !item.InformationObjectAddress.HasValue)
{
return exact;
}

// Field devices sometimes answer with a live ASDU CA that differs from the configured
// FAT/SAT profile CA. The Values workspace is an operator view, so keep mapping by IOA
// when the runtime CA is different; the dedicated runtime CA warning explains the mismatch.
// Without this fallback, real values appear as a second unmapped row while the profile row
// remains stuck in the old "waiting for GI" placeholder state.
return _ioaProfile.Points.FirstOrDefault(point =>
point.Ioa == item.InformationObjectAddress.Value &&
(!point.TypeId.HasValue || !item.TypeId.HasValue || point.TypeId.Value == item.TypeId.Value));
// No inferred IOA-only mapping on a CA mismatch. A differently addressed
// station is a different physical point; it remains visible but unmapped.
if (!item.InformationObjectAddress.HasValue) return null;
return _ioaProfile.Points
.Where(p => p.Ioa == item.InformationObjectAddress.Value &&
(!(p.Ca ?? _ioaProfile.CommonAddress).HasValue ||
(p.Ca ?? _ioaProfile.CommonAddress) == item.CommonAddressNumber) &&
(!p.TypeId.HasValue || p.TypeId == item.TypeId))
.OrderByDescending(p => p.Ca.HasValue)
.ThenByDescending(p => p.TypeId.HasValue)
.FirstOrDefault();
}

private static string ExtractSimpleStateToken(string value)
Expand Down Expand Up @@ -177,7 +174,25 @@ private void UpdateValueAndEventViews(Iec103MasterEvidenceEvent item)
_lastDisplayedValueByKey.TryGetValue(key, out var previousValueBeforeUpdate);
if (shouldShowValue)
{
MarkGiValueReceived(key);
// Only a point compatible with the configured CA/Type can satisfy GI.
if (ioaPoint is not null) MarkGiValueReceived(item);
// In dual-link mode (or wildcard-CA mapping) an actual physical point
// has a different key than the seeded SINGLE placeholder. Remove the
// stale placeholder without merging Link A/B process snapshots.
if (ioaPoint is not null && item.InformationObjectAddress.HasValue)
{
var pendingKey = Iec10xObservationIdentity.ValueKey(item.ProtocolMode, null,
ioaPoint.Ca ?? _ioaProfile.CommonAddress, item.InformationObjectAddress.Value);
if (!string.Equals(pendingKey, key, StringComparison.OrdinalIgnoreCase) &&
_valueRowsByKey.TryGetValue(pendingKey, out var pending) &&
string.IsNullOrWhiteSpace(pending.EvidenceReference))
{
_valueRowsByKey.Remove(pendingKey);
_lastDisplayedValueByKey.Remove(pendingKey);
_valueHighlightExpiryByKey.Remove(pendingKey);
_valueRowsDirty = true;
}
}
}
ReportGiCompletenessIfReady(item);

Expand Down Expand Up @@ -210,6 +225,8 @@ private void UpdateValueAndEventViews(Iec103MasterEvidenceEvent item)
var valueRow = new ValueRow(new Iec103ValuePoint
{
Key = key,
PhysicalSource = item.PhysicalSource,
EvidenceReference = item.EvidenceReference,
IsMapped = item.IsMappedSignal || ioaPoint is not null,
SignalName = ioaPoint?.Name ?? (string.IsNullOrWhiteSpace(item.SignalName) ? fallbackSignal : item.SignalName),
SignalGroup = ioaPoint?.Group ?? (string.IsNullOrWhiteSpace(item.SignalGroup) ? BuildFallbackSignalGroup(item) : item.SignalGroup),
Expand Down Expand Up @@ -247,20 +264,14 @@ private void UpdateValueAndEventViews(Iec103MasterEvidenceEvent item)
}
}

if (shouldShowEdgeEvent)
var recordIec10xDigital = Iec10xObservationIdentity.ShouldRecordDigitalEvent(item, hasMeaningfulChange);
if (shouldShowEdgeEvent || (shouldShowValue && recordIec10xDigital))
{
var isIec10xDigital = item.ProtocolMode is Iec60870ProtocolMode.Iec101 or Iec60870ProtocolMode.Iec104
&& IsIec10xDigitalType(item.TypeId);
if (isIec10xDigital && !hasMeaningfulChange &&
!item.PollingReason.StartsWith("PASSIVE-", StringComparison.Ordinal))
{
// Active Master: event log is a change journal; Passive Monitor:
// an observed spontaneous ASDU is itself a protocol event even when
// the first captured value has no prior baseline.
// Event Log is a change journal, not a value mirror. Ignore OFF->OFF,
// ON->ON, and first-observed states with no trustworthy before value.
return;
}
// A protocol edge flag on invalid/unknown digital evidence must never
// overrule the source-backed event classification.
if (isIec10xDigital && !recordIec10xDigital) return;

var relayEventRow = new RelayEventRow(new Iec103RelayEventLogEntry
{
Expand All @@ -276,7 +287,13 @@ private void UpdateValueAndEventViews(Iec103MasterEvidenceEvent item)
InformationNumber = item.InformationNumber,
PreviousValue = string.IsNullOrWhiteSpace(previousValue) ? string.Empty : previousValue,
NewValue = displayValue,
EdgeReason = string.IsNullOrWhiteSpace(item.EdgeReason) ? (item.Cot ?? string.Empty) : item.EdgeReason,
EdgeReason = isIec10xDigital
? Iec10xObservationIdentity.EventReason(item,
!string.IsNullOrWhiteSpace(NormalizeStateForComparison(previousValue)),
hasMeaningfulChange)
: string.IsNullOrWhiteSpace(item.EdgeReason) ? (item.Cot ?? string.Empty) : item.EdgeReason,
PhysicalSource = item.PhysicalSource,
EvidenceReference = item.EvidenceReference,
CauseOfTransmission = item.Cot ?? string.Empty,
AsduType = item.AsduType ?? string.Empty,
RawHex = item.RawHex,
Expand Down Expand Up @@ -431,11 +448,9 @@ private static string BuildValueKey(Iec103MasterEvidenceEvent item)
{
if (item.ProtocolMode is Iec60870ProtocolMode.Iec101 or Iec60870ProtocolMode.Iec104)
{
// IEC-101/104 operator values must be keyed by IOA so seeded profile placeholders
// and live RX process values update the same row even when live CA differs from the
// configured profile CA or the decoder supplies a richer SignalKey.
return item.InformationObjectAddress.HasValue
? BuildIoaValueKey(item.InformationObjectAddress.Value)
? Iec10xObservationIdentity.ValueKey(item.ProtocolMode,
item.PhysicalSource, item.CommonAddressNumber, item.InformationObjectAddress.Value)
: $"{item.ProtocolMode}:IOA-";
}

Expand Down
6 changes: 6 additions & 0 deletions src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,9 @@ private void FeedPassiveFrameToExistingViews(PassiveObservedFrame seen)
OperatorMessage = meaning, ProtocolMeaning = meaning,
OperatorAction = "Observed only; no commands transmitted",
RawHex = rawHexOnce,
PhysicalSource = seen.StreamId < 2 ? "Link A" : "Link B",
PhysicalStreamId = seen.StreamId,
RawStreamOffset = seen.StreamOffset,
Frame = frame, LinkAddress = control.HasValue ? address : null,
TypeId = decode?.TypeId,
TypeName = decode?.TypeName ?? "",
Expand Down Expand Up @@ -710,6 +713,9 @@ private void FeedPassiveFrameToExistingViews(PassiveObservedFrame seen)
Category = "Passive Point",
PollingReason = "PASSIVE-POINT",
RawHex = rawHexOnce,
PhysicalSource = seen.StreamId < 2 ? "Link A" : "Link B",
PhysicalStreamId = seen.StreamId,
RawStreamOffset = seen.StreamOffset,
TypeId = decode.TypeId, TypeName = decode.TypeName,
CauseOfTransmission = decode.CauseOfTransmission,
CauseDetails = decode.CauseDetails,
Expand Down
Loading
Loading