diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 1e9764827..598d4e56f 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -1552,6 +1552,24 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool
if (!device.IsConnected)
return;
+ // A normal Command Dock refresh also owns a specific live model epoch.
+ // The MMS socket can remain the same while Rescan/rebinding replaces
+ // device.Signals. Neither native success nor a late failure may publish
+ // into that replacement model. Reuse the post-Write admission predicate;
+ // no second authority cache or additional MMS request is needed.
+ var expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId);
+ if (expectedMmsAssociation == Guid.Empty)
+ return;
+ var expectedModelAssociation = device.LiveValues.Association;
+ bool IsCurrentRefreshDevice() =>
+ Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ expectedMmsAssociation,
+ token => _runtime.IsControlAssociationCurrent(device.DeviceId, token),
+ expectedModelAssociation, device.LiveValues.Association,
+ device.IsConnected, true);
+ bool IsCurrentRefreshTarget(SignalDefinition candidate) =>
+ IsCurrentRefreshDevice() && device.Signals.Contains(candidate);
+
// The native runtime serializes inspection and Operate on one
// _controlCommandGate. Enqueuing three parallel UI inspections cannot
// improve MMS throughput, but places extra reads ahead of user control.
@@ -1581,19 +1599,27 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool
// read. Never place another inspection behind an active
// operator command on this association.
await System.Windows.Threading.Dispatcher.Yield(DispatcherPriority.Background);
- if (!device.IsConnected || _applicationCancellation.IsCancellationRequested ||
+ if (!IsCurrentRefreshDevice() || _applicationCancellation.IsCancellationRequested ||
device.CommandSignals.Any(active => active.ControlCommandBusy))
break;
+ if (!IsCurrentRefreshTarget(signal))
+ break;
if (!Iec61850ControlIdentity.NeedsInspection(signal, force))
continue;
signal.ControlInspectionBusy = true;
try
{
+ // Facade evaluates the model predicate before dispatch,
+ // before typed GUI publication, and before exception revoke.
+ // A check only after await here would be too late.
var capabilities = await _runtime.InspectControlAsync(
device.DeviceId,
signal,
- _applicationCancellation.Token);
+ _applicationCancellation.Token,
+ () => IsCurrentRefreshTarget(signal));
+ if (!IsCurrentRefreshTarget(signal))
+ break;
if (!signal.ControlCommandBusy)
{
signal.ControlCurrentValue = capabilities.CurrentValue;
@@ -1615,6 +1641,10 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool
}
catch (Exception ex)
{
+ // Retired inspection failures must not replace the current
+ // Command Dock's status/evidence with stale error text.
+ if (!IsCurrentRefreshTarget(signal))
+ break;
if (!signal.ControlCommandBusy)
{
signal.ControlInspectionEvidence =
@@ -1628,6 +1658,8 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool
}
}
+ if (!IsCurrentRefreshDevice())
+ return;
device.RefreshCommandSignalProjection();
RebuildControlFeedbackIndex(device);
if (ReferenceEquals(SelectedDevice, device))
diff --git a/tests/ARSAS.Tests/M217P0RefreshControlModelEpochTests.cs b/tests/ARSAS.Tests/M217P0RefreshControlModelEpochTests.cs
new file mode 100644
index 000000000..8e0fb8795
--- /dev/null
+++ b/tests/ARSAS.Tests/M217P0RefreshControlModelEpochTests.cs
@@ -0,0 +1,110 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+///
+/// Routine Command Dock refresh must obey the same MMS association and model
+/// epoch ownership as the dedicated post-Write reinspection. These tests use
+/// deterministic state changes and verify the actual WPF/native dispatch seam,
+/// without simulating a real IEC 61850 Operate.
+///
+public sealed class M217P0RefreshControlModelEpochTests
+{
+ [Fact]
+ public void RefreshTargetFenceRejectsSameSocketReplacedModelAndRetiredRows()
+ {
+ var expectedMms = Guid.NewGuid();
+ var currentMms = expectedMms;
+ const long expectedModel = 17;
+ long liveModel = expectedModel;
+ var deviceConnected = true;
+ var original = new SignalDefinition
+ {
+ Name = "CSWI1 Pos",
+ ObjectReference = "LD0/CSWI1.Pos",
+ FunctionalConstraint = "CO",
+ IsControlSignal = true
+ };
+ var replacement = new SignalDefinition
+ {
+ Name = "CSWI1 Pos (rebound)",
+ ObjectReference = original.ObjectReference,
+ FunctionalConstraint = "CO",
+ IsControlSignal = true
+ };
+ var live = new List { original };
+ bool IsCurrent(SignalDefinition candidate) =>
+ Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ expectedMms, id => id == currentMms,
+ expectedModel, liveModel, deviceConnected, live.Contains(candidate));
+
+ Assert.True(IsCurrent(original));
+ Assert.False(IsCurrent(replacement));
+
+ // The same MMS GUID does not make a rescan's replacement data model
+ // an authorized recipient of the previous asynchronous inspection.
+ live.Clear();
+ live.Add(replacement);
+ Assert.False(IsCurrent(original));
+ Assert.False(IsCurrent(original)); // duplicate-root names never grant ownership
+ liveModel++;
+ Assert.False(IsCurrent(replacement));
+
+ liveModel = expectedModel;
+ live.Clear();
+ live.Add(original);
+ currentMms = Guid.NewGuid();
+ Assert.False(IsCurrent(original));
+ currentMms = expectedMms;
+ deviceConnected = false;
+ Assert.False(IsCurrent(original));
+ }
+
+ [Fact]
+ public void RoutineRefreshPassesModelFenceToFacadeBeforeGuiMutationAndFailure()
+ {
+ DirectoryInfo? root = new(AppContext.BaseDirectory);
+ while (root is not null && !File.Exists(
+ Path.Combine(root.FullName, "MainWindow.xaml.cs")))
+ root = root.Parent;
+ Assert.NotNull(root);
+ var src = File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.xaml.cs"));
+ var begin = src.IndexOf("private async Task RefreshControlValuesAsync(",
+ StringComparison.Ordinal);
+ var end = src.IndexOf("private bool RejectCommandWhenIedFlightUnavailable(",
+ begin, StringComparison.Ordinal);
+ Assert.True(begin >= 0 && end > begin);
+ var refresh = src[begin..end];
+
+ Assert.Contains("expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId)",
+ refresh);
+ Assert.Contains("expectedModelAssociation = device.LiveValues.Association", refresh);
+ Assert.Contains("Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(",
+ refresh);
+ Assert.Contains("device.Signals.Contains(candidate)", refresh);
+ Assert.Contains("() => IsCurrentRefreshTarget(signal));", refresh);
+ Assert.Contains("if (!IsCurrentRefreshTarget(signal))", refresh);
+ Assert.Contains("if (!IsCurrentRefreshDevice())", refresh);
+ Assert.Equal(1, refresh.Split("_runtime.InspectControlAsync(").Length - 1);
+ Assert.DoesNotContain("session.Client.InspectControlAsync(", refresh);
+
+ var awaitEnd = refresh.IndexOf("() => IsCurrentRefreshTarget(signal));",
+ StringComparison.Ordinal);
+ var mutation = refresh.IndexOf("signal.ControlCurrentValue = capabilities.CurrentValue",
+ StringComparison.Ordinal);
+ Assert.True(awaitEnd >= 0 && mutation > awaitEnd);
+ var genericCatch = refresh.IndexOf("catch (Exception ex)", StringComparison.Ordinal);
+ var faultGuard = refresh.IndexOf("if (!IsCurrentRefreshTarget(signal))",
+ genericCatch, StringComparison.Ordinal);
+ var faultMutation = refresh.IndexOf("signal.ControlInspectionEvidence =",
+ genericCatch, StringComparison.Ordinal);
+ Assert.True(genericCatch >= 0 && faultGuard > genericCatch &&
+ faultMutation > faultGuard);
+ var projection = refresh.IndexOf("device.RefreshCommandSignalProjection()",
+ StringComparison.Ordinal);
+ var projectGuard = refresh.LastIndexOf("if (!IsCurrentRefreshDevice())",
+ projection, StringComparison.Ordinal);
+ Assert.True(projectGuard >= 0 && projection > projectGuard);
+ }
+}