diff --git a/MainWindow.IedBrowser.TypedDataWrite.cs b/MainWindow.IedBrowser.TypedDataWrite.cs index 2c323ad8a..d755b9216 100644 --- a/MainWindow.IedBrowser.TypedDataWrite.cs +++ b/MainWindow.IedBrowser.TypedDataWrite.cs @@ -239,7 +239,7 @@ private async void QueueIedBrowserPostWriteRead( // uniquely declared live DA identities, then enter the SAME typed MMS // Read → SSOT → visible-row pipeline as the Browser Read button. var request = IedBrowserPostWriteReadRequest.Create( - device, model, association, attempted); + device, model, association, attempted, status); if (request is null || !ReferenceEquals(SelectedDevice, device)) { IedBrowserDetailStatus.Text = diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 5ab57c471..d96d7e9d0 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -550,7 +550,13 @@ companionRequest is not null // connection and reject a previously planned schema. IedBrowserReadBindingIndex? bindingIndex = null; var cts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); - if (automatic) cts.CancelAfter(TimeSpan.FromSeconds(15)); + if (automatic) + { + if (postWriteRequest is null) + cts.CancelAfter(TimeSpan.FromSeconds(15)); + else + cts.CancelAfter(TimeSpan.FromSeconds(30)); + } // Read All may include thousands of attributes. It is cancellable by the // operator, never a new continuous acquisition/polling process. _iedBrowserReadOperation = cts; @@ -559,6 +565,9 @@ companionRequest is not null if (!automatic || currentPage?.Kind != "SGCB") _iedBrowserReadFailureEvidence = null; var sourceDevice = device; + // A post-Write refresh must prove NEW exact association-bound MMS evidence; + // a cached pre-Write observation is never a successful readback. + var postWriteBaselineRevision = sourceDevice.LiveValues.Version; var successes = 0; var failures = 0; var unverifiedReadTargets = 0; @@ -771,6 +780,35 @@ bool HasTerminalFailure(IedBrowserReadTarget target) : "\nFirst unavailable attributes (no scalar was published):\n" + string.Join("\n", failureExamples)); RefreshIedBrowserVisibleValues(); + if (postWriteRequest is not null) + { + // The Browser owns this fresh Read and the visible grid. + // Verify all attempted exact reference/FC keys obtained + // a new MMS Read since the post-Write sweep started. + var verified = 0; + foreach (var target in postWriteRequest.Targets) + { + if (sourceDevice.LiveValues.TryGet(target.Reference, + target.FunctionalConstraint, out var observed, + preferReport: false) && + !observed.IsStale && + observed.Association == readAssociation && + observed.Revision > postWriteBaselineRevision && + string.Equals(observed.Source, "MMS Read", + StringComparison.Ordinal)) + verified++; + } + var writeOutcome = string.IsNullOrWhiteSpace(postWriteRequest.WriteStatus) + ? "Write attempted" : postWriteRequest.WriteStatus; + var readOutcome = verified == postWriteRequest.Targets.Count + ? $"Data Model refreshed from IED · {verified}/{postWriteRequest.Targets.Count} DA MMS Read verified." + : $"Data Model Read incomplete · {verified}/{postWriteRequest.Targets.Count} DA refreshed; " + + $"{failures} Read failed. Check the leaf before another Write."; + // A successful Read of the actual old value never upgrades + // a rejected Write into an apparent Write success. + IedBrowserDetailStatus.Text = writeOutcome + " · " + readOutcome; + IedBrowserDetailStatus.ToolTip = IedBrowserDetailStatus.Text; + } // A few permanently refused SG leaves must not block the next // finite SGCB chunk. Per-page auto admission still caps attempts // to three; this never becomes Read All or cyclic polling. @@ -782,7 +820,13 @@ bool HasTerminalFailure(IedBrowserReadTarget target) { DrainReadResults(); // retain observations completed before cancellation if (ReferenceEquals(SelectedDevice, sourceDevice)) + { IedBrowserSubtitle.Text = $"Read cancelled · {successes:N0} value(s) received; no settings changed."; + if (postWriteRequest is not null) + IedBrowserDetailStatus.Text = + postWriteRequest.WriteStatus + + " · Data Model Read interrupted · verify the exact DA before another Write."; + } } catch (Exception ex) { @@ -791,6 +835,9 @@ bool HasTerminalFailure(IedBrowserReadTarget target) { device.MarkDiscoveryFailed(ex.Message); IedBrowserSubtitle.Text = "Read failed: " + ex.Message; + if (postWriteRequest is not null) + IedBrowserDetailStatus.Text = + postWriteRequest.WriteStatus + " · Data Model not refreshed · MMS Read failed: " + ex.Message; } } finally diff --git a/Models/IedBrowserPostWriteReadRequest.cs b/Models/IedBrowserPostWriteReadRequest.cs index de4d39c07..5b71afa7d 100644 --- a/Models/IedBrowserPostWriteReadRequest.cs +++ b/Models/IedBrowserPostWriteReadRequest.cs @@ -10,13 +10,14 @@ namespace ArIED61850Tester.Models; /// public sealed record IedBrowserPostWriteReadRequest( string DeviceId, long Association, LiveIedModelDiscoveryDocument Model, - IReadOnlyList Targets) + IReadOnlyList Targets, string WriteStatus = "") { public static IedBrowserPostWriteReadRequest? Create( Iec61850MonitorDevice device, LiveIedModelDiscoveryDocument? model, long association, - IReadOnlyCollection<(string Reference, string Fc)> attempted) + IReadOnlyCollection<(string Reference, string Fc)> attempted, + string writeStatus = "") { if (model is null || !device.IsConnected || device.IsDemo || device.LiveValues.IsOffline || device.LiveValues.Association != association || @@ -47,7 +48,8 @@ public sealed record IedBrowserPostWriteReadRequest( string.IsNullOrWhiteSpace(attr.SclBType) ? attr.MmsType : attr.SclBType)); } return targets.Count == 0 ? null : - new IedBrowserPostWriteReadRequest(device.DeviceId, association, model, targets); + new IedBrowserPostWriteReadRequest(device.DeviceId, association, model, targets, + writeStatus); } public bool Matches(Iec61850MonitorDevice device) diff --git a/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs b/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs index 199c8c92d..b888fa7c8 100644 --- a/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs +++ b/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs @@ -66,6 +66,30 @@ public void BrowserOwner_QueuesScopedReadAfterModalNotAnotherPopupPreview() Assert.Contains("RefreshIedBrowserVisibleValues();", pipeline); } + [Fact] + public void PostWriteBrowserRead_DemandsNewExactSsotEvidence_NotOldPopupOrCachedData() + { + var pipeline = Source("MainWindow.IedBrowser.cs"); + Assert.Contains("var postWriteBaselineRevision = sourceDevice.LiveValues.Version;", pipeline); + Assert.Contains("observed.Revision > postWriteBaselineRevision", pipeline); + Assert.Contains("observed.Association == readAssociation", pipeline); + Assert.Contains("preferReport: false", pipeline); + Assert.Contains("string.Equals(observed.Source, \"MMS Read\"", pipeline); + Assert.Contains("postWriteRequest.Targets", pipeline); + Assert.Contains("RefreshIedBrowserVisibleValues();", pipeline); + Assert.Contains("Data Model refreshed from IED", pipeline); + Assert.Contains("Data Model Read incomplete", pipeline); + Assert.Contains("Data Model not refreshed", pipeline); + Assert.Contains("Data Model Read interrupted", pipeline); + Assert.Contains("postWriteRequest.WriteStatus", pipeline); + Assert.Contains("writeOutcome + \" · \" + readOutcome", pipeline); + Assert.Contains("IedBrowserDetailStatus.ToolTip", pipeline); + Assert.Contains("device, model, association, attempted, status", Source("MainWindow.IedBrowser.TypedDataWrite.cs")); + Assert.Contains("string WriteStatus = \"\"", Source("Models/IedBrowserPostWriteReadRequest.cs")); + Assert.Contains("TimeSpan.FromSeconds(30)", pipeline); + Assert.DoesNotContain("postWriteRequest.Targets.First().Value", pipeline); + } + [Fact] public void PostWriteReadRequest_FailsClosedAcrossReconnectOrAmbiguousDataModel() {