diff --git a/docs/EVENT_LOG_POLICY.md b/docs/EVENT_LOG_POLICY.md index cf9b777..74387bf 100644 --- a/docs/EVENT_LOG_POLICY.md +++ b/docs/EVENT_LOG_POLICY.md @@ -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. diff --git a/docs/IEC10X_M2_PHYSICAL_IDENTITY_SOE.md b/docs/IEC10X_M2_PHYSICAL_IDENTITY_SOE.md new file mode 100644 index 0000000..52886e2 --- /dev/null +++ b/docs/IEC10X_M2_PHYSICAL_IDENTITY_SOE.md @@ -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. diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs index 8139073..85edf6e 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.FrameInspector.cs @@ -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())}; " + diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.MappingValueStatus.cs b/src/ARIEC60870.Desktop/Features/MainWindow.MappingValueStatus.cs index 14a5c0a..3a6a7b1 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.MappingValueStatus.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.MappingValueStatus.cs @@ -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) @@ -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); @@ -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), @@ -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 { @@ -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, @@ -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-"; } diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs index e728ae4..5bd5a22 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs @@ -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 ?? "", @@ -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, diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.RuntimeProof.cs b/src/ARIEC60870.Desktop/Features/MainWindow.RuntimeProof.cs index 4e9025b..2377aaa 100644 --- a/src/ARIEC60870.Desktop/Features/MainWindow.RuntimeProof.cs +++ b/src/ARIEC60870.Desktop/Features/MainWindow.RuntimeProof.cs @@ -332,7 +332,7 @@ private void EmitGiCoverageMatrixVerdict(string reason) var monitorPoints = _ioaProfile.Points .Where(IsMonitorPoint) - .GroupBy(x => BuildIoaValueKey(x.Ioa), StringComparer.OrdinalIgnoreCase) + .GroupBy(x => BuildProfileGiKey(x), StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .ToArray(); @@ -350,15 +350,15 @@ private void EmitGiCoverageMatrixVerdict(string reason) } var missing = monitorPoints - .Where(point => !receivedKeys.Contains(BuildIoaValueKey(point.Ioa))) + .Where(point => !receivedKeys.Contains(BuildProfileGiKey(point))) .ToArray(); var digitalExpected = monitorPoints.Count(point => IsIec10xDigitalType(point.TypeId)); - var digitalReceived = monitorPoints.Count(point => IsIec10xDigitalType(point.TypeId) && receivedKeys.Contains(BuildIoaValueKey(point.Ioa))); + var digitalReceived = monitorPoints.Count(point => IsIec10xDigitalType(point.TypeId) && receivedKeys.Contains(BuildProfileGiKey(point))); var analogExpected = monitorPoints.Count(point => IsAnalogMeasurementType(point.TypeId)); - var analogReceived = monitorPoints.Count(point => IsAnalogMeasurementType(point.TypeId) && receivedKeys.Contains(BuildIoaValueKey(point.Ioa))); + var analogReceived = monitorPoints.Count(point => IsAnalogMeasurementType(point.TypeId) && receivedKeys.Contains(BuildProfileGiKey(point))); var otherExpected = Math.Max(0, monitorPoints.Length - digitalExpected - analogExpected); - var otherReceived = monitorPoints.Count(point => !IsIec10xDigitalType(point.TypeId) && !IsAnalogMeasurementType(point.TypeId) && receivedKeys.Contains(BuildIoaValueKey(point.Ioa))); + var otherReceived = monitorPoints.Count(point => !IsIec10xDigitalType(point.TypeId) && !IsAnalogMeasurementType(point.TypeId) && receivedKeys.Contains(BuildProfileGiKey(point))); _lastMonitorExpectedCount = monitorPoints.Length; _lastMonitorReceivedCount = Math.Max(0, monitorPoints.Length - missing.Length); @@ -777,11 +777,12 @@ private void SeedValueViewerFromIoaProfile(Iec60870ProtocolMode protocolMode) foreach (var point in ordered) { - var key = BuildIoaValueKey(point.Ioa); + var key = BuildProfileGiKey(point); _giExpectedValueKeys.Add(key); UpsertValueRowStable(new ValueRow(new Iec103ValuePoint { - Key = key, + Key = Iec10xObservationIdentity.ValueKey(protocolMode, null, + point.Ca ?? _ioaProfile.CommonAddress, point.Ioa), IsMapped = true, SignalName = point.Name, SignalGroup = string.IsNullOrWhiteSpace(point.Group) ? "Profile" : point.Group, @@ -826,14 +827,26 @@ private static bool IsCommandPoint(Iec10xPointMappingEntry point) || policy.Contains("Regulating", StringComparison.OrdinalIgnoreCase); } - private static string BuildIoaValueKey(int ioa) - => "IOA:" + ioa.ToString(System.Globalization.CultureInfo.InvariantCulture); + private string BuildProfileGiKey(Iec10xPointMappingEntry point) + => BuildGiKey(point.Ca ?? _ioaProfile.CommonAddress, point.Ioa); - private void MarkGiValueReceived(string key) + private static string BuildGiKey(int? ca, int ioa) + => $"CA:{(ca?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "*")}|IOA:{ioa}"; + + private void MarkGiValueReceived(Iec103MasterEvidenceEvent item) { - if (_giCompletenessWatchActive && _giExpectedValueKeys.Contains(key)) + if (!_giCompletenessWatchActive || !item.InformationObjectAddress.HasValue || + item.Direction != ARIEC60870.Core.Model.FrameDirection.SlaveToMaster || + item.PollingReason.StartsWith("PASSIVE-", StringComparison.Ordinal)) + return; + + var observed = BuildGiKey(item.CommonAddressNumber, item.InformationObjectAddress.Value); + if (_giExpectedValueKeys.Contains(observed)) + _giReceivedValueKeys.Add(observed); + else { - _giReceivedValueKeys.Add(key); + var wildcard = BuildGiKey(null, item.InformationObjectAddress.Value); + if (_giExpectedValueKeys.Contains(wildcard)) _giReceivedValueKeys.Add(wildcard); } } @@ -964,16 +977,15 @@ private void MarkMissingProfileRows(string displayValue, string cot, string qual { foreach (var key in _giExpectedValueKeys.Except(_giReceivedValueKeys, StringComparer.OrdinalIgnoreCase).ToArray()) { - var ioa = ParseIoaFromValueKey(key); - if (ioa < 0) - { - continue; - } - - var point = _ioaProfile.Points.FirstOrDefault(x => x.Ioa == ioa); + var point = _ioaProfile.Points.FirstOrDefault(x => + string.Equals(BuildProfileGiKey(x), key, StringComparison.OrdinalIgnoreCase)); + if (point is null) continue; + var ioa = point.Ioa; + var protocol = GetSelectedProtocolMode(); UpsertValueRowStable(new ValueRow(new Iec103ValuePoint { - Key = key, + Key = Iec10xObservationIdentity.ValueKey(protocol, null, + point.Ca ?? _ioaProfile.CommonAddress, ioa), IsMapped = true, SignalName = point?.Name ?? $"IOA {ioa}", SignalGroup = string.IsNullOrWhiteSpace(point?.Group) ? "Profile" : point!.Group, @@ -993,14 +1005,4 @@ private void MarkMissingProfileRows(string displayValue, string cot, string qual } } - private static int ParseIoaFromValueKey(string key) - { - if (string.IsNullOrWhiteSpace(key)) - { - return -1; - } - - var normalized = key.StartsWith("IOA:", StringComparison.OrdinalIgnoreCase) ? key[4..] : key; - return int.TryParse(normalized, out var ioa) ? ioa : -1; - } } diff --git a/src/ARIEC60870.Desktop/ViewModels/RelayEventRow.cs b/src/ARIEC60870.Desktop/ViewModels/RelayEventRow.cs index c7a4aa7..6d21242 100644 --- a/src/ARIEC60870.Desktop/ViewModels/RelayEventRow.cs +++ b/src/ARIEC60870.Desktop/ViewModels/RelayEventRow.cs @@ -11,6 +11,8 @@ public sealed class RelayEventRow public RelayEventRow(Iec103RelayEventLogEntry item) { Sequence = item.EvidenceSequenceNumber; + PhysicalSource = item.PhysicalSource; + EvidenceReference = item.EvidenceReference; Protocol = item.ProtocolMode switch { Iec60870ProtocolMode.Iec101 => "101", @@ -30,13 +32,17 @@ public RelayEventRow(Iec103RelayEventLogEntry item) Cot = item.CauseOfTransmission; CommonAddress = item.CommonAddress.HasValue ? item.CommonAddress.Value.ToString(CultureInfo.InvariantCulture) : "-"; IoAddress = item.InformationObjectAddress.HasValue ? item.InformationObjectAddress.Value.ToString(CultureInfo.InvariantCulture) : "-"; - Address = Protocol is "101" or "104" ? (IoAddress == "-" ? "-" : "IOA " + IoAddress) : (FunInf == "-" ? "-" : "FUN/INF " + FunInf); + Address = Protocol is "101" or "104" + ? (IoAddress == "-" ? "-" : (string.IsNullOrWhiteSpace(PhysicalSource) ? "" : PhysicalSource + " · ") + "IOA " + IoAddress) + : (FunInf == "-" ? "-" : "FUN/INF " + FunInf); Quality = string.IsNullOrWhiteSpace(item.QualityText) ? "-" : item.QualityText; Mapped = item.IsMapped ? "Yes" : "No"; RawHex = item.RawHex; } public long Sequence { get; } + public string PhysicalSource { get; } + public string EvidenceReference { get; } public string Protocol { get; } public string RelayTime { get; } public string Signal { get; } diff --git a/src/ARIEC60870.Desktop/ViewModels/ValueRow.cs b/src/ARIEC60870.Desktop/ViewModels/ValueRow.cs index 731f108..87581c7 100644 --- a/src/ARIEC60870.Desktop/ViewModels/ValueRow.cs +++ b/src/ARIEC60870.Desktop/ViewModels/ValueRow.cs @@ -15,6 +15,8 @@ public sealed class ValueRow : INotifyPropertyChanged public ValueRow(Iec103ValuePoint item) { Key = item.Key; + PhysicalSource = item.PhysicalSource; + EvidenceReference = item.EvidenceReference; Protocol = item.ProtocolMode switch { Iec60870ProtocolMode.Iec101 => "101", @@ -36,12 +38,16 @@ public ValueRow(Iec103ValuePoint item) IoaSortKey = item.InformationObjectAddress ?? int.MaxValue; TypeSortKey = item.TypeId ?? int.MaxValue; Quality = string.IsNullOrWhiteSpace(item.QualityText) ? "-" : item.QualityText; - Address = Protocol is "101" or "104" ? (IoAddress == "-" ? "-" : "IOA " + IoAddress) : (FunInf == "-" ? "-" : "FUN/INF " + FunInf); + Address = Protocol is "101" or "104" + ? (IoAddress == "-" ? "-" : (string.IsNullOrWhiteSpace(PhysicalSource) ? "" : PhysicalSource + " · ") + "IOA " + IoAddress) + : (FunInf == "-" ? "-" : "FUN/INF " + FunInf); Mapped = item.IsMapped ? "Yes" : "No"; RawHex = item.RawHex; } public string Key { get; } + public string PhysicalSource { get; } + public string EvidenceReference { get; } public string Protocol { get; } public string Signal { get; } public string Group { get; } diff --git a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs index 47dddfe..d9f2995 100644 --- a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs +++ b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkChannel.cs @@ -487,6 +487,7 @@ private async Task ReceiveOneAsync(string dataClass QualityText = asdu?.FirstObject?.QualityText ?? string.Empty, IsRelayValue = asdu is not null && (asdu.TypeId is 1 or 2 or 3 or 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 or 12 or 13 or 14 or 15 or 16 or 30 or 31 or 32 or 33 or 34 or 35 or 36 or 37), IsRelayEdgeEvent = asdu is not null && (asdu.CauseOfTransmission is 3 or 11 or 12) && (asdu.TypeId is 1 or 2 or 3 or 4 or 30 or 31), + PhysicalSource = Name, SignalKey = asdu?.FirstObject is null ? string.Empty : $"{Name}:IOA:{asdu.FirstObject.InformationObjectAddress}", SignalName = asdu?.FirstObject is null ? string.Empty : $"{Name} IOA {asdu.FirstObject.InformationObjectAddress}", SignalGroup = "IEC-101 Dual Link", @@ -614,6 +615,7 @@ private void PublishAdditionalObjectEvents(Ft12FrameDecode decoded, Iec10xAsduDe QualityText = obj.QualityText, IsRelayValue = asdu.TypeId is 1 or 2 or 3 or 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 or 12 or 13 or 14 or 15 or 16 or 30 or 31 or 32 or 33 or 34 or 35 or 36 or 37, IsRelayEdgeEvent = (asdu.CauseOfTransmission is 3 or 11 or 12) && (asdu.TypeId is 1 or 2 or 3 or 4 or 30 or 31), + PhysicalSource = Name, SignalKey = $"{Name}:IOA:{obj.InformationObjectAddress}", SignalName = $"{Name} IOA {obj.InformationObjectAddress}", SignalGroup = "IEC-101 Dual Link", diff --git a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkRedundancySession.cs b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkRedundancySession.cs index 2d4a020..7e19d3a 100644 --- a/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkRedundancySession.cs +++ b/src/ARIEC60870.Master/Iec101/Redundancy/Iec101DualLinkRedundancySession.cs @@ -1319,6 +1319,9 @@ private void AddEvent(Iec103MasterEvidenceEvent item) ProtocolMeaning = item.ProtocolMeaning, OperatorAction = item.OperatorAction, RawHex = item.RawHex, + PhysicalSource = item.PhysicalSource, + PhysicalStreamId = item.PhysicalStreamId, + RawStreamOffset = item.RawStreamOffset, ExceptionType = item.ExceptionType, ExceptionMessage = item.ExceptionMessage, ExceptionStackTrace = item.ExceptionStackTrace, diff --git a/src/ARIEC60870.Master/Iec101MasterSession.cs b/src/ARIEC60870.Master/Iec101MasterSession.cs index 1548984..f84d16b 100644 --- a/src/ARIEC60870.Master/Iec101MasterSession.cs +++ b/src/ARIEC60870.Master/Iec101MasterSession.cs @@ -1079,6 +1079,9 @@ private void AddEvent(Iec103MasterEvidenceEvent item) ProtocolMeaning = item.ProtocolMeaning, OperatorAction = item.OperatorAction, RawHex = item.RawHex, + PhysicalSource = item.PhysicalSource, + PhysicalStreamId = item.PhysicalStreamId, + RawStreamOffset = item.RawStreamOffset, ResponseTimeMs = item.ResponseTimeMs, Frame = item.Frame, ProtocolMode = Iec60870ProtocolMode.Iec101, diff --git a/src/ARIEC60870.Master/Iec104ClientSession.cs b/src/ARIEC60870.Master/Iec104ClientSession.cs index 1b5e0b8..0cad32e 100644 --- a/src/ARIEC60870.Master/Iec104ClientSession.cs +++ b/src/ARIEC60870.Master/Iec104ClientSession.cs @@ -532,6 +532,9 @@ private void AddEvent(Iec103MasterEvidenceEvent item) ProtocolMeaning = item.ProtocolMeaning, OperatorAction = item.OperatorAction, RawHex = item.RawHex, + PhysicalSource = item.PhysicalSource, + PhysicalStreamId = item.PhysicalStreamId, + RawStreamOffset = item.RawStreamOffset, ResponseTimeMs = item.ResponseTimeMs, ProtocolMode = Iec60870ProtocolMode.Iec104, LinkAddress = item.LinkAddress, diff --git a/src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs b/src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs index 9c8dda4..8ec3351 100644 --- a/src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs +++ b/src/ARIEC60870.Master/Model/Iec103MasterEvidenceEvent.cs @@ -27,6 +27,12 @@ public sealed class Iec103MasterEvidenceEvent public string ProtocolMeaning { get; init; } = string.Empty; public string OperatorAction { get; init; } = string.Empty; public string RawHex { get; init; } = string.Empty; + /// Physical observation source, independent of user signal mapping. + public string PhysicalSource { get; init; } = string.Empty; + /// Original RX journal provenance. Null for active master frames. + public int? PhysicalStreamId { get; init; } + public long? RawStreamOffset { get; init; } + public string EvidenceReference => Iec10xObservationIdentity.EvidenceReference(this); /// /// Exception metadata is captured as diagnostic evidence, not thrown to the UI thread. diff --git a/src/ARIEC60870.Master/Model/Iec103RelayEventLogEntry.cs b/src/ARIEC60870.Master/Model/Iec103RelayEventLogEntry.cs index c6c98b9..1c80657 100644 --- a/src/ARIEC60870.Master/Model/Iec103RelayEventLogEntry.cs +++ b/src/ARIEC60870.Master/Model/Iec103RelayEventLogEntry.cs @@ -11,6 +11,8 @@ namespace ARIEC60870.Master.Model; public sealed class Iec103RelayEventLogEntry { public long EvidenceSequenceNumber { get; init; } + public string PhysicalSource { get; init; } = string.Empty; + public string EvidenceReference { get; init; } = string.Empty; public string RelayTimeText { get; init; } = string.Empty; public bool RelayTimeInvalid { get; init; } public DateTime ArrivalTimeUtc { get; init; } = DateTime.UtcNow; diff --git a/src/ARIEC60870.Master/Model/Iec103ValuePoint.cs b/src/ARIEC60870.Master/Model/Iec103ValuePoint.cs index e9585d3..472b739 100644 --- a/src/ARIEC60870.Master/Model/Iec103ValuePoint.cs +++ b/src/ARIEC60870.Master/Model/Iec103ValuePoint.cs @@ -6,6 +6,8 @@ namespace ARIEC60870.Master.Model; public sealed class Iec103ValuePoint { public string Key { get; init; } = string.Empty; + public string PhysicalSource { get; init; } = string.Empty; + public string EvidenceReference { get; init; } = string.Empty; public bool IsMapped { get; init; } public string SignalName { get; init; } = string.Empty; public string SignalGroup { get; init; } = "Unmapped"; diff --git a/src/ARIEC60870.Master/Model/Iec10xObservationIdentity.cs b/src/ARIEC60870.Master/Model/Iec10xObservationIdentity.cs new file mode 100644 index 0000000..8f48295 --- /dev/null +++ b/src/ARIEC60870.Master/Model/Iec10xObservationIdentity.cs @@ -0,0 +1,60 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using ARIEC60870.Core.Model; + +namespace ARIEC60870.Master.Model; + +/// Physical point identity, independent of logical user mapping or display alias. +/// Scope is a single active session / passive capture; different links and CAs are distinct. +public static class Iec10xObservationIdentity +{ + public static string ValueKey(Iec60870ProtocolMode protocol, string? channel, int? ca, int ioa) + { + if (protocol is not (Iec60870ProtocolMode.Iec101 or Iec60870ProtocolMode.Iec104)) + throw new ArgumentOutOfRangeException(nameof(protocol)); + if (ioa is < 0 or > 0xFFFFFF) throw new ArgumentOutOfRangeException(nameof(ioa)); + if (ca is < 0 or > 0xFFFF) throw new ArgumentOutOfRangeException(nameof(ca)); + var source = string.IsNullOrWhiteSpace(channel) ? "SINGLE" : channel.Trim().ToUpperInvariant(); + return $"{(int)protocol}|{source.Length}:{source}|CA:{(ca?.ToString(CultureInfo.InvariantCulture) ?? "?")}|IOA:{ioa}"; + } + + /// Session/capture-scoped evidence reference. Original passive byte offsets + /// are NOT equivalent to active master event sequence numbers. + public static string EvidenceReference(Iec103MasterEvidenceEvent item) + { + var index = item.InformationObjectDetails?.Index ?? 0; + return item.PhysicalStreamId is int stream && item.RawStreamOffset is long offset + ? $"{(int)item.ProtocolMode}|stream:{stream}|byte:{offset}|object:{index}" + : $"{(int)item.ProtocolMode}|seq:{item.SequenceNumber}|object:{index}"; + } + + public static bool IsReceivedDigitalEvent(Iec103MasterEvidenceEvent item) + => item.Direction == FrameDirection.SlaveToMaster + && (item.ProtocolMode is Iec60870ProtocolMode.Iec101 or Iec60870ProtocolMode.Iec104) + && item.TypeId is 1 or 2 or 3 or 4 or 30 or 31 + && item.InformationObjectAddress.HasValue + && item.CauseOfTransmission is 3 or 11 or 12 + && item.Frame?.IsChecksumValid != false + && !string.Equals(item.Category, "RX Warning", StringComparison.OrdinalIgnoreCase); + + /// Preserve actual spontaneous observations, and known GI/cyclic state + /// transitions. Never make SOE events from TX, unknown, or unchanged snapshots. + public static bool ShouldRecordDigitalEvent(Iec103MasterEvidenceEvent item, bool changed) + => IsReceivedDigitalEvent(item) || + (changed && item.Direction == FrameDirection.SlaveToMaster && + (item.ProtocolMode is Iec60870ProtocolMode.Iec101 or Iec60870ProtocolMode.Iec104) && + item.TypeId is 1 or 2 or 3 or 4 or 30 or 31 && + item.InformationObjectAddress.HasValue && + item.Frame?.IsChecksumValid != false && + !string.Equals(item.Category, "RX Warning", StringComparison.OrdinalIgnoreCase)); + + public static string EventReason(Iec103MasterEvidenceEvent item, bool hasBaseline, bool changed) + { + var cause = item.CauseDetails?.Phase ?? item.CauseName; + if (IsReceivedDigitalEvent(item)) + return $"{cause} · {(changed ? "state change" : hasBaseline ? "repeat observation" : "first observation")}"; + return changed ? $"{cause} · state change" : cause; + } +} diff --git a/tests/ARIEC60870.Master.Tests/Iec10xObservationIdentityTests.cs b/tests/ARIEC60870.Master.Tests/Iec10xObservationIdentityTests.cs new file mode 100644 index 0000000..ed3c0a7 --- /dev/null +++ b/tests/ARIEC60870.Master.Tests/Iec10xObservationIdentityTests.cs @@ -0,0 +1,100 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using ARIEC60870.Core.Model; +using ARIEC60870.Master.Model; +using ARIEC60870.Master.Protocol.Iec10x; +using Xunit; + +namespace ARIEC60870.Master.Tests; + +public sealed class Iec10xObservationIdentityTests +{ + [Fact] + public void PhysicalKeySeparatesProtocolChannelCommonAddressAndIoa() + { + var a = Iec10xObservationIdentity.ValueKey(Iec60870ProtocolMode.Iec101, "Link A", 105, 77); + Assert.Equal(a, Iec10xObservationIdentity.ValueKey(Iec60870ProtocolMode.Iec101, " link a ", 105, 77)); + Assert.NotEqual(a, Iec10xObservationIdentity.ValueKey(Iec60870ProtocolMode.Iec101, "Link B", 105, 77)); + Assert.NotEqual(a, Iec10xObservationIdentity.ValueKey(Iec60870ProtocolMode.Iec101, "Link A", 106, 77)); + Assert.NotEqual(a, Iec10xObservationIdentity.ValueKey(Iec60870ProtocolMode.Iec101, "Link A", 105, 78)); + Assert.NotEqual(a, Iec10xObservationIdentity.ValueKey(Iec60870ProtocolMode.Iec104, "Link A", 105, 77)); + Assert.NotEqual(a, Iec10xObservationIdentity.ValueKey(Iec60870ProtocolMode.Iec101, "Link A", null, 77)); + } + + [Theory] + [InlineData(3, true)] + [InlineData(11, true)] + [InlineData(12, true)] + [InlineData(20, false)] + public void SpontaneousAndReturnInformationAreFactsEvenWithoutTransition(int cot, bool expected) + { + var e = new Iec103MasterEvidenceEvent { + Direction = FrameDirection.SlaveToMaster, ProtocolMode = Iec60870ProtocolMode.Iec101, + TypeId = 31, InformationObjectAddress = 77, CommonAddressNumber = 105, + CauseOfTransmission = cot, CauseName = "observed" + }; + Assert.Equal(expected, Iec10xObservationIdentity.IsReceivedDigitalEvent(e)); + if (expected) { + Assert.Contains("first observation", Iec10xObservationIdentity.EventReason(e, false, false)); + Assert.Contains("repeat observation", Iec10xObservationIdentity.EventReason(e, true, false)); + } + } + + [Fact] + public void GiSnapshotOnlyEntersSoeForKnownChangeAndNeverForTx() + { + var receivedGi = new Iec103MasterEvidenceEvent { + Direction = FrameDirection.SlaveToMaster, + ProtocolMode = Iec60870ProtocolMode.Iec101, + TypeId = 31, InformationObjectAddress = 77, + CommonAddressNumber = 105, CauseOfTransmission = 20 + }; + Assert.False(Iec10xObservationIdentity.ShouldRecordDigitalEvent(receivedGi, false)); + Assert.True(Iec10xObservationIdentity.ShouldRecordDigitalEvent(receivedGi, true)); + var sentGi = new Iec103MasterEvidenceEvent { + Direction = FrameDirection.MasterToSlave, + ProtocolMode = Iec60870ProtocolMode.Iec101, + TypeId = 31, InformationObjectAddress = 77, CauseOfTransmission = 20 + }; + Assert.False(Iec10xObservationIdentity.ShouldRecordDigitalEvent(sentGi, true)); + + var spontaneous = new Iec103MasterEvidenceEvent { + Direction = FrameDirection.SlaveToMaster, + ProtocolMode = Iec60870ProtocolMode.Iec104, + TypeId = 31, InformationObjectAddress = 77, CauseOfTransmission = 3 + }; + Assert.True(Iec10xObservationIdentity.ShouldRecordDigitalEvent(spontaneous, false)); + } + + [Fact] + public void CommandAndOutboundEventsAreNotFabricatedAsSpontaneous() + { + foreach (var direction in new[] { FrameDirection.MasterToSlave, FrameDirection.Unknown }) { + var e = new Iec103MasterEvidenceEvent { + Direction = direction, ProtocolMode = Iec60870ProtocolMode.Iec101, + TypeId = 31, InformationObjectAddress = 1, CauseOfTransmission = 3 + }; + Assert.False(Iec10xObservationIdentity.IsReceivedDigitalEvent(e)); + } + var command = new Iec103MasterEvidenceEvent { + Direction = FrameDirection.SlaveToMaster, ProtocolMode = Iec60870ProtocolMode.Iec104, + TypeId = 45, InformationObjectAddress = 1, CauseOfTransmission = 3 + }; + Assert.False(Iec10xObservationIdentity.IsReceivedDigitalEvent(command)); + } + + [Fact] + public void PassiveReferenceUsesOriginalStreamOffsetAndObjectIndex() + { + var obj = new Iec10xInformationObject { Index = 2, InformationObjectAddress = 77 }; + var e = new Iec103MasterEvidenceEvent { + ProtocolMode = Iec60870ProtocolMode.Iec101, SequenceNumber = 88, + PhysicalSource = "Link B", PhysicalStreamId = 3, RawStreamOffset = 12500, + InformationObjectDetails = obj + }; + Assert.Contains("stream:3|byte:12500|object:2", e.EvidenceReference); + var active = new Iec103MasterEvidenceEvent { SequenceNumber = 88 }; + Assert.Contains("seq:88|object:0", active.EvidenceReference); + } +}