diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index dc5c2f385..511bfcb54 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -584,12 +584,18 @@ private void UpdateIedBrowserControlStateDetails() device.LiveValues, block, pageKind, field, out var observed, out var reference)) { - row.Value = observed.IsStale ? "Offline · " + observed.Value : observed.Value; + var display = IedBrowserControlStateEvidence.FormatObservedField( + pageKind, field, observed); + row.Value = observed.IsStale ? "Offline · " + display : display; row.ObservationSource = observed.IsStale ? "Stale MMS Read" : "MMS Read"; row.ObservationTooltip = $"{reference} [{block.FunctionalConstraint}]" + $" · {observed.Source} · {observed.ObservedUtc:yyyy-MM-dd HH:mm:ss} UTC" + - $" · association {observed.Association}"; + $" · association {observed.Association}" + + (field == "LActTm" && observed.TimeBits is + { IsDecoded: true, HasWireProvenance: true } time + ? $" · wire {time.RawHex} · time quality {time.QualityHex}" + : ""); } else { diff --git a/Models/IedBrowserControlStateEvidence.cs b/Models/IedBrowserControlStateEvidence.cs index aa4c07b76..c50073eea 100644 --- a/Models/IedBrowserControlStateEvidence.cs +++ b/Models/IedBrowserControlStateEvidence.cs @@ -84,6 +84,24 @@ public static bool TryRead( } return true; } + /// + /// SGCB LActTm is a real relay value, but an all-zero wire UTC-Time + /// denotes an unset time rather than an actual group-change in 1970. + /// A formatted date alone is never proof: require exact raw MMS bytes. + /// + public static string FormatObservedField( + string pageKind, string field, IedBrowserObservedValue observed) + => pageKind == "SGCB" && field == "LActTm" && + observed.TimeBits is + { + IsDecoded: true, + HasWireProvenance: true, + SecondsSinceEpoch: 0, + FractionOfSecond24: 0 + } + ? "Not initialized" + : observed.Value; + private static string Canonical(string reference) => (reference ?? "").Trim().Replace('$', '.'); } diff --git a/Models/IedBrowserLiveValues.cs b/Models/IedBrowserLiveValues.cs index 5527871ee..6bc971fe3 100644 --- a/Models/IedBrowserLiveValues.cs +++ b/Models/IedBrowserLiveValues.cs @@ -157,7 +157,9 @@ private static bool IsTimeReference(string key) { var fcIndex = key.LastIndexOf('|'); return fcIndex > 2 && key.AsSpan(0, fcIndex).EndsWith( - ".t".AsSpan(), StringComparison.Ordinal); + ".t".AsSpan(), StringComparison.Ordinal) || + fcIndex > 2 && key.AsSpan(0, fcIndex).EndsWith( + ".LActTm".AsSpan(), StringComparison.Ordinal); } public void PublishReport(Iec61850MonitorPoint point, bool observedReportIngress = false) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 8a18c6c42..727c9324f 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1113,7 +1113,10 @@ private static string BuildDynamicDataSetName(ReportControlPlan plan) raw.RawValue.ToArray(), out var verifiedQuality)) projected.QualityEvidence = verifiedQuality; if ((reference.EndsWith(".t", StringComparison.Ordinal) || - reference.EndsWith("$t", StringComparison.Ordinal)) && + reference.EndsWith("$t", StringComparison.Ordinal) || + // SGCB LActTm is the model-verified last-active-group UTC-Time. + reference.EndsWith(".LActTm", StringComparison.Ordinal) || + reference.EndsWith("$LActTm", StringComparison.Ordinal)) && raw.Kind == ArMms.MmsDataKind.UtcTime) { var evidence = ArMms.Iec61850UtcTimeEvidence.Decode(raw); diff --git a/tests/ARSAS.Tests/M214SgLastChangedWireEvidenceTests.cs b/tests/ARSAS.Tests/M214SgLastChangedWireEvidenceTests.cs new file mode 100644 index 000000000..7d0fafd60 --- /dev/null +++ b/tests/ARSAS.Tests/M214SgLastChangedWireEvidenceTests.cs @@ -0,0 +1,56 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class M214SgLastChangedWireEvidenceTests +{ + private const string Reference = "LD/LLN0.SGCB.LActTm"; + + [Fact] + public void ZeroWireUtcTime_IsUnsetGroupChange_NotA1970EngineeringEvent() + { + // Typed fixture represents a validated raw eight-byte LActTm from + // the pinned engine's decoder; no formatted-date guessing. + var time = new Iec61850UtcTimeEvidence + { + IsDecoded = true, HasWireProvenance = true, + RawHex = "000000000000003F", + SecondsSinceEpoch = 0, FractionOfSecond24 = 0, + Quality = 0x3F, EngineeringUtc = "1970-01-01 00:00:00 UTC" + }; + Assert.True(time.HasWireProvenance); + Assert.Equal(0u, time.SecondsSinceEpoch); + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + Assert.True(ledger.PublishForAssociation(association, Reference, "SP", + time.EngineeringUtc, "", "", "MMS Read", utcTimeEvidence: time)); + Assert.True(ledger.TryGet(Reference, "SP", out var observed)); + Assert.NotNull(observed.TimeBits); + Assert.Equal("Not initialized", IedBrowserControlStateEvidence.FormatObservedField( + "SGCB", "LActTm", observed)); + Assert.Equal(time.RawHex, observed.TimeBits.RawHex); + } + + [Fact] + public void RealTimestamp_AndUnverifiedText_AreNeverRewrittenAsUnset() + { + var nonzero = new Iec61850UtcTimeEvidence + { + IsDecoded = true, HasWireProvenance = true, + RawHex = "651000000000003F", SecondsSinceEpoch = 0x65100000, + FractionOfSecond24 = 0, Quality = 0x3F, + EngineeringUtc = "2023-09-24 00:00:00 UTC" + }; + var value = new IedBrowserObservedValue( + nonzero.EngineeringUtc, "", "", "MMS Read", DateTime.UtcNow, + TimeBits: nonzero); + Assert.Equal(nonzero.EngineeringUtc, + IedBrowserControlStateEvidence.FormatObservedField("SGCB", "LActTm", value)); + var legacy = value with { Value = "1970-01-01 00:00:00 UTC", TimeBits = null }; + Assert.Equal(legacy.Value, + IedBrowserControlStateEvidence.FormatObservedField("SGCB", "LActTm", legacy)); + Assert.Equal(legacy.Value, + IedBrowserControlStateEvidence.FormatObservedField("GoCB", "LActTm", legacy)); + } +}