diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs
index 21a055141..5a03abc5c 100644
--- a/MainWindow.IedBrowser.cs
+++ b/MainWindow.IedBrowser.cs
@@ -1707,7 +1707,13 @@ void Put(string name, string value, string authority)
var wireSlots = _iedBrowserDetails.Rows
.Where(row => row.GooseSlot >= 0 && row.GooseReference.Length == 0)
.ToArray();
- var limit = Math.Min(frameView.Leaves.Count, 128);
+ // Unbound allData is evidence only if the ENTIRE received PDU
+ // has complete, contiguous wire slots. Do not project a partial
+ // decoder/model mismatch as real GOOSE values or as FCDA identity.
+ var unbound = ordered is null
+ ? IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView)
+ : null;
+ var limit = Math.Min(unbound?.Count ?? 0, 128);
if (ordered is not null)
{
foreach (var slot in wireSlots)
@@ -1751,9 +1757,9 @@ _iedBrowserGooseDirectory is { } directory &&
{
if (row.GooseSlot < 0) continue;
if (row.GooseReference.Length == 0 &&
- ordered is null && row.GooseSlot < frameView.Leaves.Count)
+ ordered is null && unbound is not null && row.GooseSlot < unbound.Count)
{
- row.Value = frameView.Leaves[row.GooseSlot].Value;
+ row.Value = unbound[row.GooseSlot].Value;
row.ObservationSource = "GOOSE raw allData · unbound";
row.ObservationTooltip =
$"allData[{row.GooseSlot + 1}] from {frameView.GoCbRef} " +
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 44e26f5ff..1e9764827 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -1822,6 +1822,14 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
DismissControlShout();
var clickStopwatch = Stopwatch.StartNew();
+ Guid dispatchedAssociation = Guid.Empty;
+ bool IsRetiredAssociation() =>
+ dispatchedAssociation != Guid.Empty &&
+ !_runtime.IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation);
+ void LogStaleResult() => AddLog("WARN", device.Name,
+ $"CONTROL_STALE_RESULT_IGNORED: {signal.ObjectReference}; " +
+ $"sequence={claim.Sequence}; association={dispatchedAssociation:N}; " +
+ "no old control result published into the current GUI model.");
signal.ControlLastResult = $"Dispatching {claim.RequestedValue}…";
SetStatus($"{device.Name}: dispatching {signal.Name} = {claim.RequestedValue}…");
AddLog("INFO", device.Name,
@@ -1849,6 +1857,10 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
}
+ dispatchedAssociation = _runtime.GetControlAssociationId(device.DeviceId);
+ if (dispatchedAssociation == Guid.Empty)
+ throw new InvalidOperationException(
+ "Control NOT SENT: no current MMS association after connection.");
AddLog("INFO", device.Name,
$"IEC 61850 control execution started: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}; wire send is not assumed until native evidence is returned.");
var result = await _runtime.ExecuteControlAsync(
@@ -1865,8 +1877,13 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
CommandTerminationTimeoutMs = 10000,
OriginCategory = "StationControl"
},
- _applicationCancellation.Token);
+ _applicationCancellation.Token, dispatchedAssociation);
+ if (IsRetiredAssociation())
+ {
+ LogStaleResult();
+ return;
+ }
if (!string.IsNullOrWhiteSpace(result.ControlModelText))
signal.ControlModelText = result.ControlModelText;
if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")
@@ -1889,12 +1906,22 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (OperationCanceledException)
{
+ if (IsRetiredAssociation())
+ {
+ LogStaleResult();
+ return;
+ }
signal.ControlLastResult = "Command cancelled; if Operate was already dispatched, verify actual IED position.";
SetStatus($"{device.Name}: {signal.Name} command cancelled; verify the final process state.");
}
catch (InvalidOperationException ex) when (
ex.Message.Contains("command NOT SENT", StringComparison.OrdinalIgnoreCase))
{
+ if (IsRetiredAssociation())
+ {
+ LogStaleResult();
+ return;
+ }
// A rejected local admission is NOT an MMS failure and not an
// UNKNOWN process outcome. Surface the precise busy/stop reason
// instead of the generic "Command failed" control popup.
@@ -1911,6 +1938,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
(ex.Message.Contains("in progress", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("unresolved", StringComparison.OrdinalIgnoreCase)))
{
+ if (IsRetiredAssociation())
+ {
+ LogStaleResult();
+ return;
+ }
signal.ControlLastResult = "Command NOT SENT: " + ex.Message;
AddLog("WARN", device.Name,
$"CONTROL_UI_LOCAL_GATE: {signal.ObjectReference}; {ex.Message}");
@@ -1921,6 +1953,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (TimeoutException ex)
{
+ if (IsRetiredAssociation())
+ {
+ LogStaleResult();
+ return;
+ }
signal.ControlLastResult =
"Control outcome UNKNOWN — MMS flight exceeded deadline. " +
"Verify IED process state and reconnect before another command.";
@@ -1933,6 +1970,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (Exception ex)
{
+ if (IsRetiredAssociation())
+ {
+ LogStaleResult();
+ return;
+ }
signal.ControlLastResult = $"Command failed: {ex.Message}";
AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}");
SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}");
@@ -2070,19 +2112,37 @@ private async Task ReinspectConfiguredControlAsync(
return;
}
- // A successful CF Write/readback proves only ctlModel. A fresh typed
- // descriptor + CDC/ctlVal/status verification must gate Operate.
+ // Successful CF Write/readback proves only ctlModel, not Operate.
+ // Pin BOTH the native association and the model's epoch before await.
+ // A late inspection from a retired socket must not revoke, overwrite
+ // or report success for the replacement association's Command Dock.
+ var expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId);
+ if (expectedMmsAssociation == Guid.Empty)
+ {
+ SetStatus($"{device.Name}: ctlModel inspection requires a current MMS association.");
+ return;
+ }
+ bool IsCurrentPostWriteInspection() =>
+ Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ expectedMmsAssociation,
+ token => _runtime.IsControlAssociationCurrent(device.DeviceId, token),
+ writeAssociation, device.LiveValues.Association,
+ device.IsConnected, device.Signals.Contains(signal));
+
signal.ControlInspectionBusy = true;
try
{
+ // The predicate is evaluated by the facade on the WPF caller
+ // context BEFORE it mutates SignalDefinition or handles a fault.
+ // A post-await-only check here would be too late.
var capabilities = await _runtime.InspectControlAsync(
- device.DeviceId, signal, _applicationCancellation.Token);
- if (!device.IsConnected || writeAssociation != device.LiveValues.Association ||
- !device.Signals.Contains(signal))
+ device.DeviceId, signal, _applicationCancellation.Token,
+ IsCurrentPostWriteInspection);
+ if (!IsCurrentPostWriteInspection())
{
- signal.ClearLiveControlAuthority();
- signal.ControlModelText = "Auto-detect";
- SetStatus($"{device.Name}: ctlModel inspection belongs to a replaced association.");
+ AddLog("WARN", device.Name,
+ $"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; " +
+ "an obsolete MMS association cannot publish or revoke current control evidence.");
return;
}
signal.ControlCurrentValue = capabilities.CurrentValue;
@@ -2098,16 +2158,27 @@ private async Task ReinspectConfiguredControlAsync(
}
catch (OperationCanceledException)
{
+ if (!IsCurrentPostWriteInspection())
+ {
+ AddLog("WARN", device.Name,
+ $"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; cancelled after association replacement.");
+ return;
+ }
signal.ClearLiveControlAuthority();
signal.ControlModelText = "Auto-detect";
SetStatus($"{device.Name}: ctlModel reinspection cancelled; control remains disabled.");
}
catch (Exception ex)
{
+ if (!IsCurrentPostWriteInspection())
+ {
+ AddLog("WARN", device.Name,
+ $"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; retired inspection fault {ex.GetType().Name}.");
+ return;
+ }
// An incomplete read must never re-enable Operate. Do not
// masquerade as a spinner forever after an actual failure.
signal.ClearLiveControlAuthority();
- signal.ControlModelText = "Auto-detect";
signal.ControlModelText = "Verification required";
signal.ControlInspectionEvidence = $"Fresh typed ctlModel inspection failed: {ex.Message}";
signal.ControlLastResult = "Command disabled: verification required.";
@@ -2117,8 +2188,10 @@ private async Task ReinspectConfiguredControlAsync(
}
finally
{
+ // Release the original inspection's visual busy hint, but do not
+ // rebuild live control projections from a retired MMS association.
signal.ControlInspectionBusy = false;
- if (device.IsConnected)
+ if (IsCurrentPostWriteInspection())
{
device.RefreshCommandSignalProjection();
RebuildControlFeedbackIndex(device);
diff --git a/Models/IedBrowserGooseInspection.cs b/Models/IedBrowserGooseInspection.cs
index 246677872..d3fe77ffa 100644
--- a/Models/IedBrowserGooseInspection.cs
+++ b/Models/IedBrowserGooseInspection.cs
@@ -90,22 +90,52 @@ public static bool IsFrameCurrent(
return matches.Length == 1 ? matches[0] : null;
}
+ ///
+ /// Admit only a complete and contiguous received allData vector when no
+ /// exact FCDA directory can be verified. The returned entries remain
+ /// explicitly unbound: their position never proves an IEC reference or FC.
+ /// Validate the WHOLE PDU before showing even the first bounded UI rows.
+ ///
+ public static IReadOnlyList? OrderedUnboundWireEntries(
+ GooseStreamSnapshot frame)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ var count = frame.WireDataSetEntryCount;
+ if (count <= 0 || count != frame.Leaves.Count)
+ return null;
+ for (var index = 0; index < count; index++)
+ {
+ var leaf = frame.Leaves[index];
+ if (leaf.DataSetIndex != index || leaf.Order != index + 1 ||
+ string.IsNullOrWhiteSpace(leaf.Value) ||
+ leaf.Value == "")
+ return null;
+ }
+ return frame.Leaves;
+ }
+
public static IReadOnlyList? VerifiedMembers(
GooseStreamSnapshot frame, LiveIedDataSetModel directory)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(directory);
var members = directory.Members.OrderBy(x => x.Index).ToArray();
+ // A cached directory is not evidence for a different received datSet.
+ // Bind its complete reference before interpreting any FCDA leaf.
if (members.Length == 0 || frame.WireDataSetEntryCount != members.Length ||
frame.Leaves.Count != members.Length ||
- members.Select(x => x.Index).Distinct().Count() != members.Length)
+ string.IsNullOrWhiteSpace(directory.Reference) ||
+ !string.Equals(Canonical(frame.DataSetReference),
+ Canonical(directory.Reference), StringComparison.Ordinal))
return null;
- // Validate entire ordered vector before exposing any member value.
+ // Validate the full zero-based directory, one-based presentation order,
+ // and each exact wire reference/FC before publishing any member value.
for (var slot = 0; slot < members.Length; slot++)
{
var declared = members[slot];
var wire = frame.Leaves[slot];
- if (wire.DataSetIndex != slot ||
+ if (declared.Index != slot || wire.DataSetIndex != slot ||
+ wire.Order != slot + 1 ||
Canonical(wire.SignalReference) != Canonical(declared.Reference) ||
!string.Equals(wire.FunctionalConstraint, declared.FunctionalConstraint,
StringComparison.OrdinalIgnoreCase) ||
diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs
index 69bb7157e..0cc15916c 100644
--- a/Models/IedBrowserReadPlan.cs
+++ b/Models/IedBrowserReadPlan.cs
@@ -440,7 +440,15 @@ public static IReadOnlyList BuildInitialPriority(
// must be admitted through the same bounded read plan, not skipped
// because the generic attribute walker cannot see the control block.
// Only read-only NumOfSG/ActSG/LActTm; never EditSG/CnfEdit/SE.
+ // A syntactically valid SGCB/GoCB from an unrelated MMS domain is
+ // not an authority for this live association. Reuse one exact domain
+ // index for both bounded read-only control-state inventories.
+ var liveDomains = verifiedLiveModel.LogicalDevices
+ .Select(ld => ld.MmsDomain)
+ .Where(domain => !string.IsNullOrWhiteSpace(domain))
+ .ToHashSet(StringComparer.Ordinal);
var settings = verifiedLiveModel.SettingGroupControls
+ .Where(block => liveDomains.Contains(block.Domain))
.SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "SGCB"))
.GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint,
@@ -460,6 +468,7 @@ public static IReadOnlyList BuildInitialPriority(
// Limit background status reads for dense GoCB catalogs; unvisited
// objects retain honest Not read evidence.
var goose = verifiedLiveModel.GooseControlBlocks
+ .Where(block => liveDomains.Contains(block.Domain))
.SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "GoCB"))
.GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint,
diff --git a/Services/Iec61850ControlInspectionPublication.cs b/Services/Iec61850ControlInspectionPublication.cs
new file mode 100644
index 000000000..ed1cfd4c2
--- /dev/null
+++ b/Services/Iec61850ControlInspectionPublication.cs
@@ -0,0 +1,74 @@
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Single synchronous publication point for inspection evidence. The native
+/// client owns a detached signal; only an exact current association and control
+/// object may update the operator's GUI model. Never infer process state from
+/// inspection status, and never turn StatusOnly into command authority.
+///
+public static class Iec61850ControlInspectionPublication
+{
+ ///
+ /// The post-Write typed inspection is tied to two independent epochs:
+ /// the non-reusable MMS session GUID and the live model association.
+ /// Neither a canceled old read nor a late fault may clear new GUI proof.
+ ///
+ public static bool IsCurrentPostWriteTarget(
+ Guid expectedMmsAssociation, Func isCurrentMmsAssociation,
+ long expectedModelAssociation, long currentModelAssociation,
+ bool connected, bool belongsToCurrentModel)
+ {
+ ArgumentNullException.ThrowIfNull(isCurrentMmsAssociation);
+ return expectedMmsAssociation != Guid.Empty &&
+ connected && belongsToCurrentModel &&
+ expectedModelAssociation == currentModelAssociation &&
+ isCurrentMmsAssociation(expectedMmsAssociation);
+ }
+
+ public static bool TryApply(
+ SignalDefinition guiSignal, Iec61850ControlCapabilities capabilities,
+ Guid expectedAssociation, Func isCurrentAssociation)
+ {
+ ArgumentNullException.ThrowIfNull(guiSignal);
+ ArgumentNullException.ThrowIfNull(capabilities);
+ ArgumentNullException.ThrowIfNull(isCurrentAssociation);
+ if (expectedAssociation == Guid.Empty ||
+ !isCurrentAssociation(expectedAssociation) ||
+ guiSignal.ControlCommandBusy ||
+ !string.Equals(guiSignal.ObjectReference, capabilities.ObjectReference,
+ StringComparison.Ordinal))
+ return false;
+
+ // Do not preserve an old typed CDC after a failed/changed descriptor.
+ // IsOperationallyReady alone is insufficient for a read-only model.
+ var verified = capabilities.SupportsOperate &&
+ !string.IsNullOrWhiteSpace(capabilities.ControlCdc);
+ if (!verified ||
+ (guiSignal.VerifiedLiveControlCdc.Length > 0 &&
+ !string.Equals(guiSignal.VerifiedLiveControlCdc,
+ capabilities.ControlCdc, StringComparison.OrdinalIgnoreCase)))
+ guiSignal.ClearLiveControlAuthority();
+
+ // All mutations here are synchronous on the invoking UI context:
+ // a native late callback never owns an ObservableObject reference.
+ guiSignal.ClearControlConfirmation();
+ if (verified)
+ guiSignal.AcceptVerifiedLiveControlCdc(capabilities.ControlCdc);
+ else
+ guiSignal.ControlCdc = capabilities.ControlCdc;
+ guiSignal.ControlValueType = capabilities.ControlValueType;
+ guiSignal.ControlStatusReference = capabilities.StatusReference;
+ guiSignal.ControlModelReference = capabilities.ControlModelReference;
+ guiSignal.ControlModelText = capabilities.ControlModelText;
+ guiSignal.ControlCurrentValue = capabilities.CurrentValue;
+ guiSignal.ControlInspectionEvidence =
+ $"{capabilities.ControlModelReference} | {capabilities.ControlModelText} | " +
+ $"{capabilities.EngineControlServiceStatus} | {capabilities.DiscoveryEvidence}";
+ // Operability is published last, never before the typed evidence.
+ guiSignal.ControlSemanticVerified = verified;
+ guiSignal.ControlLastResult = capabilities.EngineControlServiceStatus;
+ return true;
+ }
+}
diff --git a/Services/Iec61850ControlRequestSnapshot.cs b/Services/Iec61850ControlRequestSnapshot.cs
new file mode 100644
index 000000000..9460c9849
--- /dev/null
+++ b/Services/Iec61850ControlRequestSnapshot.cs
@@ -0,0 +1,60 @@
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Makes one disposable, command-only copy of the operator's selected signal.
+/// ARIEC may update the request's SignalDefinition after an asynchronous MMS
+/// reply; a retired association must never mutate a WPF-owned signal model.
+/// The copy carries no process-value authority or extra observation cache.
+///
+public static class Iec61850ControlRequestSnapshot
+{
+ public static Iec61850ControlCommandRequest Create(Iec61850ControlCommandRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ return new Iec61850ControlCommandRequest
+ {
+ Signal = CreateSignal(request.Signal),
+ ValueText = request.ValueText,
+ InterlockCheck = request.InterlockCheck,
+ SynchroCheck = request.SynchroCheck,
+ TestMode = request.TestMode,
+ Originator = request.Originator,
+ OriginCategory = request.OriginCategory,
+ FeedbackTimeoutMs = request.FeedbackTimeoutMs,
+ CommandTerminationTimeoutMs = request.CommandTerminationTimeoutMs
+ };
+ }
+
+ ///
+ /// Detached command/control inspection model created synchronously on the
+ /// caller's thread. This is not a live value cache or a new MMS authority.
+ /// No background native task may retain the GUI-owned SignalDefinition.
+ ///
+ public static SignalDefinition CreateSignal(SignalDefinition signal)
+ {
+ ArgumentNullException.ThrowIfNull(signal);
+ var copy = new SignalDefinition
+ {
+ Name = signal.Name,
+ ObjectReference = signal.ObjectReference,
+ FunctionalConstraint = signal.FunctionalConstraint,
+ DataType = signal.DataType,
+ Category = signal.Category,
+ IsControlSignal = signal.IsControlSignal,
+ ControlCdc = signal.ControlCdc,
+ ControlStatusReference = signal.ControlStatusReference,
+ ControlValueType = signal.ControlValueType,
+ ControlModelReference = signal.ControlModelReference,
+ ControlModelText = signal.ControlModelText,
+ ControlCurrentValue = signal.ControlCurrentValue,
+ ControlSemanticVerified = signal.ControlSemanticVerified
+ };
+ // Preserve only the authority already verified by the current MMS
+ // association. Never promote DataSet/SCL advisory CDC to live proof.
+ if (!string.IsNullOrWhiteSpace(signal.VerifiedLiveControlCdc))
+ copy.AcceptVerifiedLiveControlCdc(signal.VerifiedLiveControlCdc);
+ return copy;
+ }
+}
diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs
index 0f783af40..7610cd52c 100644
--- a/Services/Iec61850MonitorRuntime.cs
+++ b/Services/Iec61850MonitorRuntime.cs
@@ -1283,8 +1283,12 @@ public async Task InspectControlAsync(
{
using var inspectionCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, session.ControlLifetimeCancellation.Token);
+ // Native inspection changes CDC, ctlModel and feedback after awaits,
+ // including on failure. Use private state so retired sessions can
+ // never mutate a WPF-owned SignalDefinition behind the facade.
+ var detachedSignal = Iec61850ControlRequestSnapshot.CreateSignal(signal);
var capabilities = await session.Client.InspectControlAsync(
- signal, inspectionCancellation.Token).ConfigureAwait(false);
+ detachedSignal, inspectionCancellation.Token).ConfigureAwait(false);
if (!_sessions.TryGetValue(deviceId, out var current) ||
!ReferenceEquals(current, session) ||
session.ControlLifetimeCancellation.IsCancellationRequested)
@@ -1311,16 +1315,34 @@ public bool IsControlFlightUnavailable(string deviceId)
(Iec61850ControlFlightAdmission.IsUnavailable(
ref session.ControlCommandActive, ref session.ControlFlightQuarantined));
+ // Each connection owns a non-reusable identity. No device ID or control
+ // reference alone may authorize UI results after Stop/Reconnect.
+ public Guid GetControlAssociationId(string deviceId)
+ => _sessions.TryGetValue(deviceId, out var session) &&
+ session.Client.IsConnected &&
+ !session.ControlLifetimeCancellation.IsCancellationRequested
+ ? session.ControlSessionIdentity : Guid.Empty;
+
+ public bool IsControlAssociationCurrent(string deviceId, Guid associationId)
+ => associationId != Guid.Empty &&
+ GetControlAssociationId(deviceId) == associationId;
+
public async Task ExecuteControlAsync(
string deviceId,
Iec61850ControlCommandRequest request,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ Guid expectedAssociation = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
ArgumentNullException.ThrowIfNull(request);
if (!_sessions.TryGetValue(deviceId, out var session) || !session.Client.IsConnected)
throw new InvalidOperationException("The IED must be connected before a command can be sent.");
+ if (expectedAssociation != Guid.Empty &&
+ (session.ControlSessionIdentity != expectedAssociation ||
+ session.ControlLifetimeCancellation.IsCancellationRequested))
+ throw new InvalidOperationException(
+ "Control NOT SENT: MMS association changed since operator confirmation.");
// One physical control target per device, regardless of DataSet aliases.
// Failed concurrent admission is local: no additional MMS Operate.
@@ -1354,7 +1376,12 @@ public async Task ExecuteControlAsync(
{
Log("INFO", session.Device.Name,
$"Control execution requested: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}; IED acceptance is determined only from native MMS wire evidence.");
- nativeFlight = session.Client.ExecuteControlAsync(request, nativeCancellation.Token);
+ // The native client writes decoded ctlModel/CDC/feedback after awaits.
+ // It must NEVER hold the WPF-owned mutable SignalDefinition alias:
+ // an old association can complete after the caller's deadline.
+ var detachedRequest = Iec61850ControlRequestSnapshot.Create(request);
+ nativeFlight = session.Client.ExecuteControlAsync(
+ detachedRequest, nativeCancellation.Token);
result = await Iec61850ControlFlightAdmission.WaitForNativeAsync(
nativeFlight, ControlFlightDeadline, cancellationToken,
() => Iec61850ControlFlightAdmission.Quarantine(ref session.ControlFlightQuarantined),
diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
index 525477f2f..ef84f3939 100644
--- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
+++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
@@ -184,14 +184,61 @@ public Task> StartMonitoringAsync(
cancellationToken,
token => _inner.ProbeControlModelAsync(deviceId, signal, token));
- public Task InspectControlAsync(
+ public async Task InspectControlAsync(
string deviceId,
SignalDefinition signal,
- CancellationToken cancellationToken)
- => RunDeviceOperationAsync(
- deviceId,
- cancellationToken,
- token => _inner.InspectControlAsync(deviceId, signal, token));
+ CancellationToken cancellationToken,
+ Func? stillCurrentPublicationTarget = null)
+ {
+ ArgumentNullException.ThrowIfNull(signal);
+ // Caller-owned model epoch is checked on the invoking UI context.
+ // The runtime's own nonreusable association GUID remains mandatory.
+ if (stillCurrentPublicationTarget is not null &&
+ !stillCurrentPublicationTarget())
+ throw new OperationCanceledException(
+ "Control inspection NOT SENT: the selected live model changed.");
+ // Snapshot before the first await on the invoking GUI context. Runtime
+ // performs the I/O on its worker lane; native never receives this
+ // WPF-owned signal or a reference it can mutate after disconnect.
+ var association = _inner.GetControlAssociationId(deviceId);
+ if (association == Guid.Empty)
+ throw new InvalidOperationException(
+ "Control inspection NOT SENT: no current MMS association.");
+ var detached = Iec61850ControlRequestSnapshot.CreateSignal(signal);
+ try
+ {
+ // Deliberately capture the caller context for the final view-model
+ // publication; RunDeviceOperationAsync owns the background work.
+ var capabilities = await RunDeviceOperationAsync(
+ deviceId, cancellationToken,
+ token => _inner.InspectControlAsync(deviceId, detached, token));
+ if (signal.ControlCommandBusy ||
+ (stillCurrentPublicationTarget is not null &&
+ !stillCurrentPublicationTarget()) ||
+ !Iec61850ControlInspectionPublication.TryApply(
+ signal, capabilities, association,
+ expected => _inner.IsControlAssociationCurrent(deviceId, expected)))
+ throw new OperationCanceledException(
+ "Control inspection result belongs to an old association, " +
+ "a different control object, or an active command.");
+ return capabilities;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Native inspection failure used to revoke stale live authority.
+ // Do it only for this same association, on the caller's GUI
+ // context; never let an old native task mutate a newer row.
+ if (!signal.ControlCommandBusy &&
+ (stillCurrentPublicationTarget is null ||
+ stillCurrentPublicationTarget()) &&
+ _inner.IsControlAssociationCurrent(deviceId, association))
+ {
+ signal.ClearLiveControlAuthority();
+ signal.ControlModelText = "Auto-detect";
+ }
+ throw;
+ }
+ }
// The UI and the inner MMS runtime intentionally have different types.
// ctlModel editing must cross this facade, just like inspection/Operate;
@@ -205,10 +252,17 @@ public Task InspectControlAsync(
cancellationToken,
token => _inner.ConfigureControlModelAsync(deviceId, signal, expected, requested, token));
+ public Guid GetControlAssociationId(string deviceId)
+ => _inner.GetControlAssociationId(deviceId);
+
+ public bool IsControlAssociationCurrent(string deviceId, Guid associationId)
+ => _inner.IsControlAssociationCurrent(deviceId, associationId);
+
public Task ExecuteControlAsync(
string deviceId,
Iec61850ControlCommandRequest request,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ Guid expectedAssociation = default)
// An IEC command cannot silently queue behind a bulk discovery,
// browser Read All or lifecycle operation. If the single association
// is busy, fail locally before a wire write; NEVER bypass its IO gate.
@@ -223,7 +277,8 @@ public Task ExecuteControlAsync(
request.ValueText);
try
{
- return await _inner.ExecuteControlAsync(deviceId, request, token).ConfigureAwait(false);
+ return await _inner.ExecuteControlAsync(
+ deviceId, request, token, expectedAssociation).ConfigureAwait(false);
}
finally
{
diff --git a/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs b/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs
index 81873c29b..1218bd76f 100644
--- a/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs
+++ b/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs
@@ -50,6 +50,51 @@ public void UnverifiedOrConflictingSgcbNeverTriggersProbe(
Assert.Empty(IedBrowserControlStateEvidence.ReadTargets(block, "SGCB"));
}
+ [Fact]
+ public void InitialControlStateHydrationRejectsForeignMmsDomain()
+ {
+ var valid = Block(fields: ["ActSG"]);
+ var foreign = new LiveIedControlBlockModel
+ {
+ Name = "SGCB", Kind = "SettingGroupControl",
+ Domain = "FOREIGN", LogicalNode = "LLN0",
+ Reference = "FOREIGN/LLN0.SP.SGCB",
+ FunctionalConstraint = "SP", DiscoveryStatus = "AttributeInventoryOnly",
+ Attributes = ["ActSG"]
+ };
+ var localGoose = Block("GSEControl", "GO", "BCU",
+ fields: ["GoEna"]);
+ var foreignGoose = new LiveIedControlBlockModel
+ {
+ Name = "BCU", Kind = "GSEControl",
+ Domain = "FOREIGN", LogicalNode = "LLN0",
+ Reference = "FOREIGN/LLN0.GO.BCU",
+ FunctionalConstraint = "GO", DiscoveryStatus = "AttributeInventoryOnly",
+ Attributes = ["GoEna"]
+ };
+ var model = new LiveIedModelDiscoveryDocument
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
+ SettingGroupControls = [foreign, valid],
+ GooseControlBlocks = [foreignGoose, localGoose]
+ };
+ var ledger = new IedBrowserLiveValues();
+ ledger.BeginAssociation();
+ var admitted = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32);
+ Assert.Contains(admitted, target => target.Reference == "LD/LLN0.SGCB.ActSG");
+ Assert.Contains(admitted, target => target.Reference == "LD/LLN0.BCU.GoEna");
+ Assert.DoesNotContain(admitted, target =>
+ target.Reference.StartsWith("FOREIGN/", StringComparison.Ordinal));
+
+ model = new LiveIedModelDiscoveryDocument
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
+ SettingGroupControls = [foreign],
+ GooseControlBlocks = [foreignGoose]
+ };
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32));
+ }
+
[Fact]
public void GocbGoEnaAndNdsComAreMmsReads_NotPassiveFrameAssumptions()
{
diff --git a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs
index 70148c3f8..f734c2590 100644
--- a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs
+++ b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs
@@ -33,6 +33,49 @@ public void ExactCanonicalControlIdentityAndOnePublisherRequired()
Assert.Null(IedBrowserGooseInspection.Select(Expected(), [right, Frame()]));
}
+ [Fact]
+ public void UnboundAllDataRejectsIncompleteDuplicateOrMissingRealWireSlots()
+ {
+ var first = new GooseLeafValueSnapshot(1, 0, "wire[1]", "", "", "",
+ "", "Closed [10]", "", false, "Unbound");
+ var second = new GooseLeafValueSnapshot(2, 1, "wire[2]", "", "", "",
+ "", "Good", "", false, "Unbound");
+ var frame = Frame(leaves: [first, second]);
+ var slots = IedBrowserGooseInspection.OrderedUnboundWireEntries(frame);
+ Assert.NotNull(slots);
+ Assert.Equal(2, slots.Count);
+ Assert.Same(first, slots[0]);
+ Assert.All(slots, slot => Assert.Empty(slot.SignalReference));
+ Assert.All(slots, slot => Assert.Empty(slot.FunctionalConstraint));
+
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { WireDataSetEntryCount = 3 }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [second, first] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [first, first] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [first, second with { Order = 7 }] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [first, second with { Value = "" }] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [first, second with { Value = "" }] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [] }));
+
+ // The UI displays at most 128 entries; an invalid *129th* wire
+ // slot must still invalidate the entire PDU, including the first 128.
+ var large = Enumerable.Range(0, 129)
+ .Select(i => new GooseLeafValueSnapshot(i + 1, i,
+ $"wire[{i + 1}]", "", "", "", "", "Good", "", false, "Unbound"))
+ .ToArray();
+ Assert.Equal(129, IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ Frame(leaves: large))?.Count);
+ large[128] = large[128] with { DataSetIndex = 127 };
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ Frame(leaves: large)));
+ }
+
[Fact]
public void ExactSclGoCbMetadata_ProjectsOnlyConfiguredVlanAndTimingFields()
{
@@ -78,8 +121,28 @@ public void WireDataSetValuesOnlyIfWholeOrderedMemberIdentityMatches()
{ Leaves = [b, a] }, set));
Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with
{ WireDataSetEntryCount = 1 }, set));
+ Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with
+ { DataSetReference = "LD/LLN0$DIFFERENT" }, set));
+ Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with
+ { Leaves = [a, b with { Order = 1 }] }, set));
Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with
{ Leaves = [a, b with { FunctionalConstraint = "MX" }] }, set));
+ var nonContiguous = new LiveIedDataSetModel
+ {
+ Reference = set.Reference,
+ Members =
+ [
+ new LiveIedDataSetMemberModel
+ {
+ Index = 1, Reference = first.Reference, FunctionalConstraint = "ST"
+ },
+ new LiveIedDataSetMemberModel
+ {
+ Index = 2, Reference = second.Reference, FunctionalConstraint = "ST"
+ }
+ ]
+ };
+ Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, nonContiguous));
var ambiguous = new LiveIedDataSetModel { Reference = set.Reference,
Members = [first, first] };
Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, ambiguous));
@@ -229,6 +292,7 @@ public void BrowserHasUnboundWireAllDataFallbackWithoutRewritingLedger()
"MainWindow.IedBrowser.cs"));
Assert.Contains("ResolveWireDataSet(", source);
Assert.Contains("allData[{slot + 1}] · unbound", source);
+ Assert.Contains("IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView)", source);
Assert.Contains("GOOSE raw allData · unbound", source);
Assert.Contains("Not current", source);
Assert.Contains("Received GOOSE PDU datSet", source);
diff --git a/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs b/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs
new file mode 100644
index 000000000..b2db7c9a1
--- /dev/null
+++ b/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs
@@ -0,0 +1,118 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+
+namespace ARSAS.Tests;
+
+///
+/// Retains the unique live-wire and initial-SGCB admission contracts of
+/// historical #606 against the CURRENT RC API. No obsolete UI navigation
+/// assertions, no second GOOSE decoder, no MMS mutation and no device I/O.
+///
+public sealed class M217DCurrentTreeEvidenceContinuityTests
+{
+ [Fact]
+ public void ExactWireGoCbCanBeReadUnboundWithoutAnMmsDataSetInventory()
+ {
+ var first = new GooseLeafValueSnapshot(1, 0, "raw-1", "", "", "", "",
+ "Open [01]", "", false, "Unbound");
+ var second = new GooseLeafValueSnapshot(2, 1, "raw-2", "", "", "", "",
+ "Good", "", false, "Unbound");
+ var frame = new GooseStreamSnapshot(
+ "received-wire", "0x0021", "LD/LLN0$GO$BCU", "BCUGE03",
+ "LD/LLN0$GOOSE1", "02:00:4C:4F:5B:50",
+ "01:0C:CD:01:00:33", "VID 0 / PCP 4",
+ "1", "42", "Healthy", "20000 ms", "1",
+ "", "Unbound", "", "12:00:00.000", 1, 0,
+ false, false, [first, second],
+ "0", "4", 2);
+ // There is no MMS/SCL DatSet inventory and publisher IED label is
+ // absent. Only the full received GoCB reference authorizes a match.
+ var expected = new IedBrowserGooseExpectedIdentity(
+ "BCUGE", "LD/LLN0.GO.BCU", "", "BCUGE03",
+ 33, 1, "", "", null);
+ Assert.Same(frame, IedBrowserGooseInspection.Select(expected, [frame]));
+ var raw = IedBrowserGooseInspection.OrderedUnboundWireEntries(frame);
+ Assert.NotNull(raw);
+ Assert.Equal(2, raw.Count);
+ Assert.Equal("Good", raw[1].Value);
+ Assert.All(raw, slot => Assert.Empty(slot.SignalReference));
+ Assert.All(raw, slot => Assert.Empty(slot.FunctionalConstraint));
+ Assert.Null(IedBrowserGooseInspection.Select(
+ expected with { GoCbRef = "" }, [frame]));
+ Assert.Null(IedBrowserGooseInspection.Select(
+ expected, [frame with { GoCbRef = "LD/LLN0$GO$OTHER" }]));
+ Assert.Null(IedBrowserGooseInspection.Select(
+ expected, [frame, frame]));
+ Assert.Null(IedBrowserGooseInspection.Select(
+ expected, [frame with { AppIdText = "0x0022" }]));
+ }
+
+ [Fact]
+ public void ExactLiveSgcbInitialReadIsBoundedReadOnlyAndSkipsObservedFields()
+ {
+ var block = new LiveIedControlBlockModel
+ {
+ Kind = "SettingGroupControl",
+ Domain = "LD", LogicalNode = "LLN0", Name = "SGCB",
+ FunctionalConstraint = "SP",
+ DiscoveryStatus = "AttributeInventoryOnly",
+ Reference = "LD/LLN0.SP.SGCB",
+ Attributes = ["NumOfSG", "ActSG", "LActTm", "EditSG", "CnfEdit"]
+ };
+ var model = new LiveIedModelDiscoveryDocument
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
+ SettingGroupControls = [block]
+ };
+ var ledger = new IedBrowserLiveValues();
+ var association = ledger.BeginAssociation();
+ var original = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32);
+ Assert.Equal(3, original.Count);
+ Assert.All(original, entry => Assert.Equal("SP", entry.FunctionalConstraint));
+ Assert.Equal(new[] { "ActSG", "LActTm", "NumOfSG" },
+ original.Select(target => target.Reference.Split('.')[^1])
+ .OrderBy(name => name, StringComparer.Ordinal).ToArray());
+ Assert.DoesNotContain(original, target =>
+ target.Reference.Contains("EditSG", StringComparison.Ordinal) ||
+ target.Reference.Contains("CnfEdit", StringComparison.Ordinal));
+
+ Assert.True(ledger.PublishForAssociation(association,
+ "LD/LLN0.SGCB.ActSG", "SP", "1", "", "", "MMS Read"));
+ var pending = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32);
+ Assert.Equal(2, pending.Count);
+ Assert.DoesNotContain(pending, target =>
+ target.Reference == "LD/LLN0.SGCB.ActSG");
+ Assert.Single(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 1));
+ ledger.MarkOffline();
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32));
+ }
+
+ [Fact]
+ public void UnverifiedSclOrWrongFcNeverBecomesAnInitialSgcbRead()
+ {
+ LiveIedModelDiscoveryDocument Create(string status, string fc) => new()
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
+ SettingGroupControls =
+ [
+ new LiveIedControlBlockModel
+ {
+ Kind = "SettingGroupControl", Domain = "LD",
+ LogicalNode = "LLN0", Name = "SGCB",
+ FunctionalConstraint = fc,
+ DiscoveryStatus = status,
+ Reference = $"LD/LLN0.{fc}.SGCB",
+ Attributes = ["NumOfSG", "ActSG", "EditSG", "CnfEdit"]
+ }
+ ]
+ };
+ var ledger = new IedBrowserLiveValues();
+ ledger.BeginAssociation();
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(
+ Create("SCL", "SP"), ledger, 32));
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(
+ Create("AttributeInventoryOnly", "CF"), ledger, 32));
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(
+ Create("AttributeInventoryOnly", "SP"), ledger, 0));
+ }
+}
diff --git a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs
index 6132ed923..94d503b51 100644
--- a/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs
+++ b/tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs
@@ -1,4 +1,5 @@
using System.Threading;
+using ArIED61850Tester.Models;
using ArIED61850Tester.Services;
namespace ARSAS.Tests;
@@ -242,4 +243,265 @@ public void BrowserMustRejectNewConfirmationBeforeClaimingPhysicalCommand()
Assert.Contains("CONTROL_UI_TIMEOUT", code);
Assert.Contains("Control outcome UNKNOWN", code);
}
+ [Fact]
+ public async Task DetachedLateFlightCannotMutateGuiOwnedSignalAfterCancellation()
+ {
+ var gui = new ArIED61850Tester.Models.SignalDefinition
+ {
+ Name = "CSWI6 Pos", ObjectReference = "LD/CSWI6.Pos",
+ IsControlSignal = true, ControlCdc = "DPC",
+ ControlStatusReference = "LD/CSWI6.Pos.stVal",
+ ControlSemanticVerified = true,
+ ControlCurrentValue = "Open [01]"
+ };
+ gui.AcceptVerifiedLiveControlCdc("DPC");
+ // SignalDefinition normalizes operator labels when live CDC is
+ // accepted ("Open [01]" can become "Open"). Assert immutability
+ // against the GUI's actual pre-flight state, not a display spelling.
+ var originalGuiValue = gui.ControlCurrentValue;
+ var request = new Iec61850ControlCommandRequest
+ {
+ Signal = gui, ValueText = "Closed [10]", TestMode = true,
+ FeedbackTimeoutMs = 12000
+ };
+ var detached = Iec61850ControlRequestSnapshot.Create(request);
+ Assert.NotSame(gui, detached.Signal);
+ Assert.Equal("DPC", detached.Signal.VerifiedLiveControlCdc);
+
+ var nativeFinished = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ using var userCancelled = new CancellationTokenSource();
+ var quarantine = 0;
+ var cancelledNative = 0;
+ var waiter = Iec61850ControlFlightAdmission.WaitForNativeAsync(
+ nativeFinished.Task, TimeSpan.FromMinutes(1),
+ userCancelled.Token,
+ () => Interlocked.Exchange(ref quarantine, 1),
+ () => Interlocked.Exchange(ref cancelledNative, 1));
+ userCancelled.Cancel();
+ await Assert.ThrowsAnyAsync(async () => await waiter);
+ Assert.Equal(1, Volatile.Read(ref quarantine));
+ Assert.Equal(1, Volatile.Read(ref cancelledNative));
+
+ // The real native task did not finish when its caller stopped waiting.
+ // Late completion may mutate only its private detached signal copy.
+ detached.Signal.ControlCurrentValue = "Closed [10]";
+ detached.Signal.ControlSemanticVerified = false;
+ nativeFinished.TrySetResult(1);
+ Assert.Equal(originalGuiValue, gui.ControlCurrentValue);
+ Assert.True(gui.ControlSemanticVerified);
+ Assert.Equal(1, Volatile.Read(ref quarantine));
+ }
+
+ [Fact]
+ public void OperatorControlPublicationUsesNonreusableAssociationFence()
+ {
+ 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 ui = File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.xaml.cs"));
+ var runtime = File.ReadAllText(Path.Combine(root.FullName,
+ "Services/Iec61850MonitorRuntime.cs"));
+ var facade = File.ReadAllText(Path.Combine(root.FullName,
+ "Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"));
+ Assert.Contains("Guid dispatchedAssociation = Guid.Empty;", ui);
+ Assert.Contains("IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation)", ui);
+ Assert.Contains("CONTROL_STALE_RESULT_IGNORED", ui);
+ Assert.Contains("_applicationCancellation.Token, dispatchedAssociation", ui);
+ Assert.Contains("session.ControlSessionIdentity != expectedAssociation", runtime);
+ Assert.Contains("Iec61850ControlRequestSnapshot.Create(request)", runtime);
+ Assert.Contains("detachedRequest, nativeCancellation.Token", runtime);
+ Assert.Contains("_inner.IsControlAssociationCurrent(deviceId, associationId)", facade);
+ }
+
+ [Fact]
+ public void DetachedReadOnlyInspectionAndAssociationPublicationCannotReplayOldProof()
+ {
+ var gui = new SignalDefinition
+ {
+ Name = "CSWI6 Pos", ObjectReference = "LD/CSWI6.Pos",
+ IsControlSignal = true, FunctionalConstraint = "CO",
+ ControlCdc = "DPC", ControlModelText = "Auto-detect",
+ ControlCurrentValue = "Open"
+ };
+ var detached = Iec61850ControlRequestSnapshot.CreateSignal(gui);
+ Assert.NotSame(gui, detached);
+ detached.ControlCurrentValue = "Closed";
+ detached.ControlModelText = "Direct Operate (DO) • Normal security";
+ detached.AcceptVerifiedLiveControlCdc("DPC");
+ detached.ControlSemanticVerified = true;
+ Assert.NotEqual(detached.ControlCurrentValue, gui.ControlCurrentValue);
+ Assert.False(gui.ControlSemanticVerified);
+ Assert.Empty(gui.VerifiedLiveControlCdc);
+
+ var old = Guid.NewGuid();
+ var current = Guid.NewGuid();
+ var ready = new Iec61850ControlCapabilities
+ {
+ ObjectReference = gui.ObjectReference,
+ StatusReference = "LD/CSWI6.Pos.stVal",
+ ControlModelReference = "LD/CSWI6.Pos.ctlModel",
+ ControlModel = Iec61850ControlModelKind.DirectNormal,
+ ControlModelText = "Direct Operate (DO) • Normal security",
+ ControlCdc = "DPC", ControlValueType = "Dbpos",
+ CurrentValue = "Closed", IsOperationallyReady = true,
+ EngineControlServiceAvailable = true,
+ EngineControlServiceStatus = "Live typed descriptor verified"
+ };
+ Assert.False(Iec61850ControlInspectionPublication.TryApply(
+ gui, ready, old, id => id == current));
+ Assert.Equal("Open", gui.ControlCurrentValue);
+ Assert.False(gui.ControlSemanticVerified);
+ var foreignObject = new Iec61850ControlCapabilities
+ {
+ ObjectReference = "LD/CSWI7.Pos",
+ ControlModel = Iec61850ControlModelKind.DirectNormal,
+ ControlCdc = "DPC", IsOperationallyReady = true,
+ EngineControlServiceAvailable = true
+ };
+ Assert.False(Iec61850ControlInspectionPublication.TryApply(
+ gui, foreignObject, current, _ => true));
+ Assert.True(Iec61850ControlInspectionPublication.TryApply(
+ gui, ready, current, id => id == current));
+ Assert.Equal("Closed", gui.ControlCurrentValue);
+ Assert.Equal("DPC", gui.VerifiedLiveControlCdc);
+ Assert.True(gui.ControlSemanticVerified);
+ Assert.True(gui.ControlSupportsOperate);
+
+ var statusOnly = new Iec61850ControlCapabilities
+ {
+ ObjectReference = gui.ObjectReference,
+ ControlModel = Iec61850ControlModelKind.StatusOnly,
+ ControlModelText = "Status only",
+ ControlCdc = "DPC", CurrentValue = "-",
+ EngineControlServiceAvailable = true,
+ IsOperationallyReady = false,
+ EngineControlServiceStatus = "Status-only MMS read"
+ };
+ Assert.True(Iec61850ControlInspectionPublication.TryApply(
+ gui, statusOnly, current, id => id == current));
+ Assert.False(gui.ControlSemanticVerified);
+ Assert.Empty(gui.VerifiedLiveControlCdc);
+ Assert.False(gui.ControlSupportsOperate);
+ Assert.Equal("Status only", gui.ControlModelText);
+ Assert.Contains("Status only", gui.ControlLastResult);
+ Assert.Contains("read-only", gui.ControlLastResult, StringComparison.OrdinalIgnoreCase);
+ }
+
+
+ [Fact]
+ public void ControlInspectionNativeUsesOnlyDetachedSignalAndUiPublicationFence()
+ {
+ DirectoryInfo? root = new(AppContext.BaseDirectory);
+ while (root is not null &&
+ !File.Exists(Path.Combine(root.FullName,
+ "Services/Iec61850MonitorRuntime.cs")))
+ root = root.Parent;
+ Assert.NotNull(root);
+ var runtime = File.ReadAllText(Path.Combine(root!.FullName,
+ "Services/Iec61850MonitorRuntime.cs"));
+ var facade = File.ReadAllText(Path.Combine(root.FullName,
+ "Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"));
+ Assert.Contains(
+ "var detachedSignal = Iec61850ControlRequestSnapshot.CreateSignal(signal);",
+ runtime);
+ Assert.Contains("detachedSignal, inspectionCancellation.Token", runtime);
+ Assert.Contains("var detached = Iec61850ControlRequestSnapshot.CreateSignal(signal);",
+ facade);
+ Assert.Contains("Iec61850ControlInspectionPublication.TryApply(", facade);
+ Assert.Contains("_inner.IsControlAssociationCurrent(deviceId, expected)", facade);
+ Assert.Contains("signal.ControlCommandBusy", facade);
+ Assert.Contains("signal.ClearLiveControlAuthority();", facade);
+ }
+
+ [Fact]
+ public void PostWriteInspectionRejectsRetiredMmsAndLiveModelEpochs()
+ {
+ var oldMms = Guid.NewGuid();
+ var currentMms = Guid.NewGuid();
+ const long writeModelEpoch = 54;
+ bool Current(Guid association) => association == currentMms;
+ Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ Guid.Empty, Current, writeModelEpoch, writeModelEpoch, true, true));
+ Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ oldMms, Current, writeModelEpoch, writeModelEpoch, true, true));
+ Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ currentMms, Current, writeModelEpoch, writeModelEpoch + 1, true, true));
+ Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ currentMms, Current, writeModelEpoch, writeModelEpoch, false, true));
+ Assert.False(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ currentMms, Current, writeModelEpoch, writeModelEpoch, true, false));
+ Assert.True(Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
+ currentMms, Current, writeModelEpoch, writeModelEpoch, true, true));
+ }
+
+ [Fact]
+ public void PostWriteLateCancellationAndFailureNeverRevokeCurrentControlProof()
+ {
+ 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 code = File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.xaml.cs"));
+ var begin = code.IndexOf("private async Task ReinspectConfiguredControlAsync(", StringComparison.Ordinal);
+ var end = code.IndexOf("private void RemoveControlFeedbackIndex(", begin, StringComparison.Ordinal);
+ Assert.True(begin >= 0 && end > begin);
+ var postWrite = code[begin..end];
+ Assert.Contains("expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId)", postWrite);
+ Assert.Contains("Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(", postWrite);
+ Assert.Contains("writeAssociation, device.LiveValues.Association", postWrite);
+ Assert.Contains("CTL_MODEL_POSTWRITE_STALE_IGNORED", postWrite);
+ Assert.Contains("IsCurrentPostWriteInspection);", postWrite);
+ Assert.Contains("predicate is evaluated by the facade", postWrite,
+ StringComparison.OrdinalIgnoreCase);
+ var guardCount = postWrite.Split("if (!IsCurrentPostWriteInspection())").Length - 1;
+ Assert.Equal(3, guardCount); // success, cancellation, fault
+ var cancelled = postWrite.IndexOf("catch (OperationCanceledException)", StringComparison.Ordinal);
+ var failed = postWrite.IndexOf("catch (Exception ex)", StringComparison.Ordinal);
+ Assert.True(cancelled > 0 && failed > cancelled);
+ Assert.True(postWrite.IndexOf("if (!IsCurrentPostWriteInspection())", cancelled,
+ StringComparison.Ordinal) < postWrite.IndexOf("signal.ClearLiveControlAuthority();", cancelled,
+ StringComparison.Ordinal));
+ Assert.True(postWrite.IndexOf("if (!IsCurrentPostWriteInspection())", failed,
+ StringComparison.Ordinal) < postWrite.IndexOf("signal.ClearLiveControlAuthority();", failed,
+ StringComparison.Ordinal));
+ Assert.Contains("if (IsCurrentPostWriteInspection())", postWrite);
+ }
+
+ [Fact]
+ public void FacadeChecksModelEpochBeforePublishingAndBeforeFailureRevocation()
+ {
+ DirectoryInfo? root = new(AppContext.BaseDirectory);
+ while (root is not null &&
+ !File.Exists(Path.Combine(root.FullName,
+ "Services/UiResponsiveIec61850MonitorRuntimeFacade.cs")))
+ root = root.Parent;
+ Assert.NotNull(root);
+ var code = File.ReadAllText(Path.Combine(root!.FullName,
+ "Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"));
+ var begin = code.IndexOf(
+ "public async Task InspectControlAsync(",
+ StringComparison.Ordinal);
+ var end = code.IndexOf("public Task",
+ begin, StringComparison.Ordinal);
+ Assert.True(begin >= 0 && end > begin);
+ var inspect = code[begin..end];
+ Assert.Contains("Func? stillCurrentPublicationTarget = null", inspect);
+ Assert.Contains("Control inspection NOT SENT: the selected live model changed", inspect);
+ Assert.Contains("!Iec61850ControlInspectionPublication.TryApply(", inspect);
+ var apply = inspect.IndexOf("!Iec61850ControlInspectionPublication.TryApply(",
+ StringComparison.Ordinal);
+ Assert.True(inspect.LastIndexOf("!stillCurrentPublicationTarget()", apply,
+ StringComparison.Ordinal) >= 0);
+ var catchBegin = inspect.IndexOf("catch (Exception ex)", StringComparison.Ordinal);
+ var revoke = inspect.IndexOf("signal.ClearLiveControlAuthority();",
+ catchBegin, StringComparison.Ordinal);
+ Assert.True(catchBegin >= 0 && revoke > catchBegin);
+ Assert.True(inspect.IndexOf("stillCurrentPublicationTarget()", catchBegin,
+ StringComparison.Ordinal) < revoke);
+ }
+
}