From e03dab4382e6fa9b23adb018ffecc40f8382aab2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:42:09 +0700 Subject: [PATCH 1/8] fix(p0-g): include model-proven SV OR BL leaves in explicit Browser Read only --- Models/IedBrowserReadPlan.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 0ce1bf267..224458a8b 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -28,8 +28,19 @@ public static IedBrowserReadSelectionKind SelectionKind(IedBrowserTreeRow? page) _ => IedBrowserReadSelectionKind.FcAttributes }; + // The live MMS model may declare status-only substitution (SV), operation + // supervision (OR), and blocking (BL) leaves. They are safe to *read* + // explicitly, never to operate or modify. FC CO remains excluded. private static readonly HashSet ReadableFc = new( - ["ST", "MX", "CF", "DC", "SP", "SG", "SE", "EX"], StringComparer.OrdinalIgnoreCase); + ["ST", "MX", "CF", "DC", "SP", "SG", "SE", "EX", "SV", "OR", "BL"], + StringComparer.OrdinalIgnoreCase); + + // Focused navigation hydration runs without an explicit operator sweep. + // Keep its historical bounded FC set, so merely opening XCBR1 does not + // start extra substitution, blocking or operation-supervision reads. + private static readonly HashSet AutoFocusedFc = new( + ["ST", "MX", "CF", "DC", "SP", "SG", "SE", "EX"], + StringComparer.OrdinalIgnoreCase); /// /// FC CO denotes command parameters, not scalar MMS Read targets. @@ -150,6 +161,7 @@ public static IReadOnlyList BuildFocused( // Do not publish another reader's result as though this association // observed it. Existing Report/MMS Read evidence is always reused. var ordered = metadata.Concat(candidates) + .Where(target => AutoFocusedFc.Contains(target.FunctionalConstraint)) .Where(target => !ledger.TryGet(target.Reference, target.FunctionalConstraint, out _)) // Failed targets must not monopolize a subsequent bounded SGCB From 225e4bf768420efca0cff1dede3eb74ee32f2434 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:42:54 +0700 Subject: [PATCH 2/8] perf(p0-g): isolate and coalesce Browser Read progress per IED without discovery overlay churn --- Models/MonitorModels.cs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 7eb4c70b6..29fe1da67 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -20,6 +20,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject private bool _isStopping; private bool _isMonitoring; private bool _isBrowserReading; + private double _browserReadProgressPercent; private bool _isActive; private bool _hasReportStream; private bool _isDemo; @@ -392,7 +393,22 @@ public bool IsStopping public bool IsBrowserReading { get => _isBrowserReading; - set => Set(ref _isBrowserReading, value); + set + { + // Every explicit or bounded focused sweep starts at zero, without + // resetting discovery telemetry or reallocating a UI overlay. + if (value && !_isBrowserReading) + BrowserReadProgressPercent = 0d; + Set(ref _isBrowserReading, value); + } + } + + // Presentation-only Browser Read progress, isolated from discovery. + // The IED card reserves its thin progress slot even while hidden. + public double BrowserReadProgressPercent + { + get => _browserReadProgressPercent; + private set => Set(ref _browserReadProgressPercent, Math.Clamp(value, 0d, 100d)); } public bool IsMonitoring @@ -484,12 +500,16 @@ public string DiscoveryProgressPercentText public void ApplyBrowserReadProgress(int completed, int total) { - if (total <= 0) return; + if (total <= 0 || !IsBrowserReading) return; var processed = Math.Clamp(completed, 0, total); + var percent = Math.Round(100d * processed / total, 0, + MidpointRounding.AwayFromZero); + // Thousands of leaf Read results must not trigger thousands of WPF + // property notifications. One update per integer percent plus final. + if (BrowserReadProgressPercent == percent && processed != total) return; + BrowserReadProgressPercent = percent; BusyStage = $"Read-only MMS values ยท {processed:N0} / {total:N0}"; - _discoveryProgressTargetPercent = 100d * processed / total; DiscoveryProgressStepText = $"Value {processed:N0} of {total:N0}"; - DiscoveryProgressPercentText = $"{DiscoveryProgressPercent:0}%"; } public void ResetDiscoveryProgress( From 4ec58f4d8cd3d61447001616c8b1c17ae2422460 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:43:23 +0700 Subject: [PATCH 3/8] ui(p0-g): fixed-height thin Browser Read line below IED IP; stop full-card flicker overlay --- MainWindow.xaml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index c14ff0b23..3bd82dad0 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -377,6 +377,17 @@ + + + + @@ -481,7 +492,8 @@ - + + From 620cc210772f19dd56d0143a5c5053c5cf3c16f2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:44:39 +0700 Subject: [PATCH 4/8] test(p0-g): exact SV OR BL Read coverage and fixed-height per-IED Browser progress UX --- ...SubstitutionReadAndIedCardProgressTests.cs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs diff --git a/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs b/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs new file mode 100644 index 000000000..ecfd46308 --- /dev/null +++ b/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs @@ -0,0 +1,159 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class P0GBrowserSubstitutionReadAndIedCardProgressTests +{ + private const string LnRef = "SIEBCUCTRL/XCBR1"; + + private static LiveIedDataAttributeModel Attribute( + string doName, string leaf, string fc, bool wireKnown = true) => new() + { + ObjectReference = LnRef + "." + doName, + AttributePath = doName + "." + leaf, + FunctionalConstraint = fc, + MmsReference = wireKnown + ? LnRef + "$" + fc + "$" + doName + "$" + leaf : "", + SclBType = fc == "OR" ? "Boolean" : fc == "SV" ? "Boolean" : "Boolean", + TypeDiscoveryStatus = "Exact" + }; + + private static (LiveIedModelDiscoveryDocument Model, IedBrowserTreeRow Page) + XcbrPage() + { + var ln = new LiveIedLogicalNodeModel + { + Name = "XCBR1", + LnClass = "XCBR", + DataObjects = + [ + new LiveIedDataObjectModel + { + Name = "Pos", Reference = LnRef + ".Pos", + Attributes = + [ + Attribute("Pos", "stVal", "ST"), + Attribute("Pos", "subEna", "SV"), + Attribute("Pos", "subVal", "SV"), + Attribute("Pos", "subQ", "SV"), + Attribute("Pos", "subID", "SV"), + Attribute("Pos", "opRcvd", "OR"), + Attribute("Pos", "opOk", "OR"), + Attribute("Pos", "tOpOk", "OR"), + Attribute("Pos", "blkEna", "BL"), + Attribute("Pos", "Oper", "CO"), + Attribute("Pos", "notVerified", "SV", wireKnown: false) + ] + } + ] + }; + var model = new LiveIedModelDiscoveryDocument + { + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "SIEBCUCTRL", + LogicalNodes = [ln] + } + ] + }; + return (model, new IedBrowserTreeRow( + "SIEBCUCTRL/XCBR1", "XCBR1", "LN", 2, ln)); + } + + [Fact] + public void ExplicitReadLnIncludesModelVerifiedSubstitutionOperationAndBlocking_WithoutControl() + { + var (model, page) = XcbrPage(); + var selected = IedBrowserReadPlan.Build(model, page, readAll: false); + foreach (var (leaf, fc) in new[] + { + ("subEna", "SV"), ("subVal", "SV"), ("subQ", "SV"), + ("subID", "SV"), ("opRcvd", "OR"), ("opOk", "OR"), + ("tOpOk", "OR"), ("blkEna", "BL") + }) + { + Assert.Contains(selected, target => + target.Reference == LnRef + "$" + fc + "$Pos$" + leaf && + target.FunctionalConstraint == fc); + } + Assert.Contains(selected, target => target.FunctionalConstraint == "ST"); + Assert.DoesNotContain(selected, target => + target.FunctionalConstraint == "CO" || + target.Reference.Contains("notVerified", StringComparison.Ordinal)); + var ordered = IedBrowserReadPlan.PrioritizeExplicitLn(selected); + Assert.Equal(selected.Count, ordered.Count); + } + + [Fact] + public void PassiveNavigationDoesNotAutomaticallyReadSubstitutionOrBlockingOrOperationState() + { + var (model, page) = XcbrPage(); + var ledger = new IedBrowserLiveValues(); + ledger.BeginAssociation(); + var passive = IedBrowserReadPlan.BuildFocused(model, page, ledger); + Assert.Contains(passive, target => target.FunctionalConstraint == "ST"); + Assert.DoesNotContain(passive, target => + target.FunctionalConstraint is "SV" or "OR" or "BL" or "CO"); + } + + [Fact] + public void ProgressLineHasFixedSpaceBelowIpAndReadNeverRevealsFullCardOverlay() + { + var xaml = XDocument.Load(FindFile("MainWindow.xaml")); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var progress = xaml.Descendants().Single(e => + (string?)e.Attribute(x + "Name") == "BrowserReadProgressLine"); + Assert.Equal("ProgressBar", progress.Name.LocalName); + Assert.Equal("3", (string?)progress.Attribute("Height")); + Assert.Equal("0", (string?)progress.Attribute("Opacity")); + Assert.Contains("BrowserReadProgressPercent", (string?)progress.Attribute("Value")); + var reserved = progress.Parent!; + Assert.Equal("Grid", reserved.Name.LocalName); + Assert.Equal("7", (string?)reserved.Attribute("Height")); + var ip = reserved.Parent!.Elements().Single(e => + (string?)e.Attribute("Text") == "{Binding EndpointText}"); + Assert.Equal("TextBlock", ip.Name.LocalName); + var readTrigger = xaml.Descendants().Single(e => + e.Name.LocalName == "DataTrigger" && + (string?)e.Attribute("Binding") == "{Binding IsBrowserReading}"); + var setter = Assert.Single(readTrigger.Elements()); + Assert.Equal("BrowserReadProgressLine", (string?)setter.Attribute("TargetName")); + Assert.Equal("Opacity", (string?)setter.Attribute("Property")); + Assert.Equal("1", (string?)setter.Attribute("Value")); + Assert.DoesNotContain(readTrigger.Descendants(), e => + (string?)e.Attribute("TargetName") == "BusyOverlay"); + } + + [Fact] + public void ProgressIsPerDeviceResetsBetweenSweepsAndDoesNotAffectDiscoveryState() + { + var device = new Iec61850MonitorDevice(); + device.IsBrowserReading = true; + device.ApplyBrowserReadProgress(3, 4); + Assert.Equal(75d, device.BrowserReadProgressPercent); + device.IsBrowserReading = false; + device.IsBrowserReading = true; + Assert.Equal(0d, device.BrowserReadProgressPercent); + device.ApplyBrowserReadProgress(4, 4); + Assert.Equal(100d, device.BrowserReadProgressPercent); + device.IsBrowserReading = false; + device.ApplyBrowserReadProgress(1, 2); + Assert.Equal(100d, device.BrowserReadProgressPercent); + } + + private static string FindFile(string name) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var path = Path.Combine(dir.FullName, name); + if (File.Exists(path)) return path; + dir = dir.Parent; + } + throw new FileNotFoundException(name); + } +} From 3e34ebe7e60d72cdd940dad9300b29ba967ba1d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:48:54 +0700 Subject: [PATCH 5/8] test(p0-g): exercise explicit Read All FC type fidelity and fail-closed unverified leaf exclusion --- ...SubstitutionReadAndIedCardProgressTests.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs b/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs index ecfd46308..4356d17dc 100644 --- a/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs +++ b/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs @@ -16,7 +16,9 @@ private static LiveIedDataAttributeModel Attribute( FunctionalConstraint = fc, MmsReference = wireKnown ? LnRef + "$" + fc + "$" + doName + "$" + leaf : "", - SclBType = fc == "OR" ? "Boolean" : fc == "SV" ? "Boolean" : "Boolean", + SclBType = leaf == "subVal" ? "Dbpos" : + leaf == "subQ" ? "Quality" : + leaf == "tOpOk" ? "Timestamp" : "Boolean", TypeDiscoveryStatus = "Exact" }; @@ -88,6 +90,25 @@ public void ExplicitReadLnIncludesModelVerifiedSubstitutionOperationAndBlocking_ Assert.Equal(selected.Count, ordered.Count); } + [Fact] + public void ExplicitReadAllKeepsModelTypesAndDoesNotAdmitSclOnlyOrControl() + { + var (model, page) = XcbrPage(); + var all = IedBrowserReadPlan.Build(model, page, readAll: true); + Assert.Contains(all, target => + target.Reference == LnRef + "$SV$Pos$subVal" && + target.FunctionalConstraint == "SV" && + target.DataType == "Dbpos"); + Assert.Contains(all, target => + target.Reference == LnRef + "$SV$Pos$subQ" && + target.DataType == "Quality"); + Assert.Contains(all, target => target.FunctionalConstraint == "OR"); + Assert.Contains(all, target => target.FunctionalConstraint == "BL"); + Assert.DoesNotContain(all, target => + target.FunctionalConstraint == "CO" || + target.Reference.Contains("notVerified", StringComparison.Ordinal)); + } + [Fact] public void PassiveNavigationDoesNotAutomaticallyReadSubstitutionOrBlockingOrOperationState() { From ac844026e7d10705eb96886cf081845b9a74f050 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:51:26 +0700 Subject: [PATCH 6/8] refactor(p0-g): keep one FC allowlist with minimal explicit-only FC gate --- Models/IedBrowserReadPlan.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index 224458a8b..dab093c42 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -35,12 +35,13 @@ public static IedBrowserReadSelectionKind SelectionKind(IedBrowserTreeRow? page) ["ST", "MX", "CF", "DC", "SP", "SG", "SE", "EX", "SV", "OR", "BL"], StringComparer.OrdinalIgnoreCase); - // Focused navigation hydration runs without an explicit operator sweep. - // Keep its historical bounded FC set, so merely opening XCBR1 does not - // start extra substitution, blocking or operation-supervision reads. - private static readonly HashSet AutoFocusedFc = new( - ["ST", "MX", "CF", "DC", "SP", "SG", "SE", "EX"], - StringComparer.OrdinalIgnoreCase); + // A passive view may read existing safe FCs, but never implicitly probe + // substitution, operation supervision or blocking. One authoritative + // readable-FC catalog is shared with explicit operator Read. + private static bool RequiresExplicitRead(string? fc) + => string.Equals(fc, "SV", StringComparison.OrdinalIgnoreCase) || + string.Equals(fc, "OR", StringComparison.OrdinalIgnoreCase) || + string.Equals(fc, "BL", StringComparison.OrdinalIgnoreCase); /// /// FC CO denotes command parameters, not scalar MMS Read targets. @@ -161,7 +162,7 @@ public static IReadOnlyList BuildFocused( // Do not publish another reader's result as though this association // observed it. Existing Report/MMS Read evidence is always reused. var ordered = metadata.Concat(candidates) - .Where(target => AutoFocusedFc.Contains(target.FunctionalConstraint)) + .Where(target => !RequiresExplicitRead(target.FunctionalConstraint)) .Where(target => !ledger.TryGet(target.Reference, target.FunctionalConstraint, out _)) // Failed targets must not monopolize a subsequent bounded SGCB From ee1b079cd4181a8161b3e66f95d93d5f7c68ac9f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:52:48 +0700 Subject: [PATCH 7/8] fix(p0-g): keep Browser Read micro-progress owned by original IED across selection changes --- MainWindow.IedBrowser.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 588da198e..33e456be6 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -605,8 +605,10 @@ void DrainReadResults() _iedBrowserReadFailureEvidence = new IedBrowserReadFailureEvidence(sourceDevice); var readProgress = new Progress(p => { + // Progress belongs to the device that owns this read flight, + // not whichever IED card the operator happens to select now. + // The flight token and association still fence late callbacks. if (ReferenceEquals(_iedBrowserReadOperation, cts) && - ReferenceEquals(SelectedDevice, sourceDevice) && !cts.IsCancellationRequested && sourceDevice.LiveValues.Association == readAssociation) sourceDevice.ApplyBrowserReadProgress(p.Completed, p.Total); From 3367f33450987d098f63bd604883bbefa2b3c010 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:53:14 +0700 Subject: [PATCH 8/8] test(p0-g): guard original IED progress ownership across operator navigation --- ...owserSubstitutionReadAndIedCardProgressTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs b/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs index 4356d17dc..490446c7f 100644 --- a/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs +++ b/tests/ARSAS.Tests/P0GBrowserSubstitutionReadAndIedCardProgressTests.cs @@ -166,6 +166,20 @@ public void ProgressIsPerDeviceResetsBetweenSweepsAndDoesNotAffectDiscoveryState Assert.Equal(100d, device.BrowserReadProgressPercent); } + [Fact] + public void ReadProgressFollowsOwningAssociationWhenOperatorSelectsAnotherIed() + { + var source = File.ReadAllText(FindFile("MainWindow.IedBrowser.cs")); + var callback = source.Split( + "var readProgress = new Progress(p =>", + StringSplitOptions.None)[1].Split( + "if (device.IsMonitoring)", StringSplitOptions.None)[0]; + Assert.Contains("sourceDevice.LiveValues.Association == readAssociation", callback); + Assert.Contains("ReferenceEquals(_iedBrowserReadOperation, cts)", callback); + Assert.DoesNotContain("ReferenceEquals(SelectedDevice, sourceDevice)", callback); + Assert.Contains("sourceDevice.ApplyBrowserReadProgress", callback); + } + private static string FindFile(string name) { var dir = new DirectoryInfo(AppContext.BaseDirectory);