From c8a6442b9145b6d5a11bba9181131b0a2c7c755d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:13:38 +0700 Subject: [PATCH 1/8] fix(browser): localize explicitly UTC-stamped IEC values without guessing timezone --- Models/IedBrowserLocalTimePresentation.cs | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Models/IedBrowserLocalTimePresentation.cs diff --git a/Models/IedBrowserLocalTimePresentation.cs b/Models/IedBrowserLocalTimePresentation.cs new file mode 100644 index 000000000..0c7e951b3 --- /dev/null +++ b/Models/IedBrowserLocalTimePresentation.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace ArIED61850Tester.Models; + +/// +/// Operator-local IEC UTC display only. Never alter the original MMS value, +/// wire time-quality or comparison/evidence source. Unknown timezone means +/// the value may already be local, so refuse to shift it a second time. +/// +public static class IedBrowserLocalTimePresentation +{ + private static readonly Regex ExplicitZone = new( + @"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z| UTC| ?[+-]\d{2}:\d{2})$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public static string Format(string? raw, TimeZoneInfo? localZone = null) + { + var value = (raw ?? "").Trim(); + if (!ExplicitZone.IsMatch(value)) + return value; + var parsable = value.EndsWith(" UTC", StringComparison.Ordinal) + ? value[..^4] + "+00:00" : value; + if (!DateTimeOffset.TryParse(parsable, CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces, out var instant)) + return value; + var local = TimeZoneInfo.ConvertTime(instant, localZone ?? TimeZoneInfo.Local); + return local.ToString("yyyy-MM-dd HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture); + } +} From 6225a4a404a55b6140050970f6663f26418b3512 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:13:59 +0700 Subject: [PATCH 2/8] fix(browser): local time for explicit UTC ST/MX timestamp rows and SG last-changed --- MainWindow.IedBrowser.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 660075318..479c1740f 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -609,6 +609,14 @@ private void RefreshIedBrowserVisibleValues() row.ValueReference, row.FunctionalConstraint, live.Value); else display = "Not exposed"; + // IEC UTC wire timestamps are UTC instants, unlike offsetless + // vendor display strings that may already be operator-local. + // Convert only when explicit UTC/offset evidence exists. + if (row.EvidenceField.Length == 0 && + (row.EvidenceKind == "Timestamp" || + row.ValueReference.EndsWith(".t", StringComparison.Ordinal) || + row.ValueReference.EndsWith(".LActTm", StringComparison.Ordinal))) + display = IedBrowserLocalTimePresentation.Format(display); if (row.IsEngineeringValue && row.EvidenceField.Length == 0) display = IedBrowserEngineeringValuePresentation.Format( display, IedBrowserEngineeringValuePresentation.ResolveUnit( @@ -651,6 +659,8 @@ private void UpdateIedBrowserControlStateDetails() { var display = IedBrowserControlStateEvidence.FormatObservedField( pageKind, field, observed); + if (field == "LActTm") + display = IedBrowserLocalTimePresentation.Format(display); row.Value = observed.IsStale ? "Offline · " + display : display; row.ObservationSource = observed.IsStale ? "Stale MMS Read" : "MMS Read"; From 3ff0e3b6fe2c63ed672a3d71dc2b96ece61c6dfd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:14:22 +0700 Subject: [PATCH 3/8] fix(browser): bind raw q/t companions to closest exact nested CDC leaf and matching FC --- Models/IedBrowserSchemaIndex.cs | 38 ++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/Models/IedBrowserSchemaIndex.cs b/Models/IedBrowserSchemaIndex.cs index c1e2dd43d..bb07caf4d 100644 --- a/Models/IedBrowserSchemaIndex.cs +++ b/Models/IedBrowserSchemaIndex.cs @@ -66,10 +66,14 @@ public IedBrowserSchemaIndex(LiveIedModelDiscoveryDocument model) !reference.StartsWith(owner + ".", StringComparison.Ordinal)) continue; var relative = reference[(owner.Length + 1)..]; - if (relative == "q") - _qualityLeaves[Key(owner, fc)] = reference; - if (relative == "t") - _timestampLeaves[Key(owner, fc)] = reference; + // A structured DO such as PPV contains CDC subobjects + // phsAB/phsBC/phsCA, each with its own q/t. Index their + // exact declared parent path instead of dropping the nested + // raw evidence or borrowing another phase's flags. + if (relative == "q" || relative.EndsWith(".q", StringComparison.Ordinal)) + _qualityLeaves[Key(reference[..^2], fc)] = reference; + if (relative == "t" || relative.EndsWith(".t", StringComparison.Ordinal)) + _timestampLeaves[Key(reference[..^2], fc)] = reference; if (!ProcessLeaves.Contains(relative)) continue; if (!primaries.TryGetValue(fc, out var paths)) { @@ -228,11 +232,19 @@ public bool TryGetQualityCompanion(string? reportReference, string? fc, out stri quality = ""; if (string.IsNullOrWhiteSpace(reportReference) || string.IsNullOrWhiteSpace(fc)) return false; - foreach (var owner in Ancestors(IedBrowserLiveValues.CanonicalReference(reportReference))) + // Closest exact schema-declared CDC q wins. An FCD nested phase + // may own its own q even when only its outer PPV is an IEC DO. + var reference = IedBrowserLiveValues.CanonicalReference(reportReference); + if (reference.EndsWith(".q", StringComparison.Ordinal) && + _qualityLeaves.TryGetValue(Key(reference[..^2], fc), out var own) && + string.Equals(own, reference, StringComparison.Ordinal)) { - if (!_dataObjects.Contains(owner)) continue; - return _qualityLeaves.TryGetValue(Key(owner, fc), out quality!); + quality = own; + return true; } + foreach (var owner in Ancestors(reference)) + if (_qualityLeaves.TryGetValue(Key(owner, fc), out quality!)) + return true; return false; } @@ -245,11 +257,17 @@ public bool TryGetTimestampCompanion(string? reportReference, string? fc, out st timestamp = ""; if (string.IsNullOrWhiteSpace(reportReference) || string.IsNullOrWhiteSpace(fc)) return false; - foreach (var owner in Ancestors(IedBrowserLiveValues.CanonicalReference(reportReference))) + var reference = IedBrowserLiveValues.CanonicalReference(reportReference); + if (reference.EndsWith(".t", StringComparison.Ordinal) && + _timestampLeaves.TryGetValue(Key(reference[..^2], fc), out var own) && + string.Equals(own, reference, StringComparison.Ordinal)) { - if (!_dataObjects.Contains(owner)) continue; - return _timestampLeaves.TryGetValue(Key(owner, fc), out timestamp!); + timestamp = own; + return true; } + foreach (var owner in Ancestors(reference)) + if (_timestampLeaves.TryGetValue(Key(owner, fc), out timestamp!)) + return true; return false; } From 4692caf78e5f35fa90a081bd9036e6b8f6d17113 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 10:15:02 +0700 Subject: [PATCH 4/8] fix(files): blue compact grid/header and visible properly stretched user-supplied vector icon --- MainWindow.xaml | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index 3085e636d..47027820e 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -2218,8 +2218,20 @@ AlternatingRowBackground="#F0F4F9" GridLinesVisibility="Horizontal" HorizontalGridLinesBrush="#EDF1F6" LoadingRow="IedBrowserFile_LoadingRow"> + + +