Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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 != "-")
Expand All @@ -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.
Expand All @@ -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}");
Expand All @@ -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.";
Expand All @@ -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}");
Expand Down
55 changes: 55 additions & 0 deletions Services/Iec61850ControlRequestSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

/// <summary>
/// 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.
/// </summary>
public static class Iec61850ControlRequestSnapshot
{
public static Iec61850ControlCommandRequest Create(Iec61850ControlCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return new Iec61850ControlCommandRequest
{
Signal = CopySignal(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
};
}

private static SignalDefinition CopySignal(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;
}
}
27 changes: 25 additions & 2 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1311,16 +1311,34 @@
(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<Iec61850ControlCommandResult> 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.
Expand Down Expand Up @@ -1354,7 +1372,12 @@
{
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),
Expand Down Expand Up @@ -2063,7 +2086,7 @@
{
if (update.HasValue &&
IedBrowserReportValueAuthority.SafeScalar(update.Value) &&
!LooksLikeReferenceEcho(update.Value, update.Reference, update.Reference))

Check warning on line 2089 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 2089 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 2089 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.
session.Device.LiveValues.PublishForAssociation(
session.BrowserObservationAssociation,
update.Reference, reportFc, update.Value,
Expand Down
12 changes: 10 additions & 2 deletions Services/UiResponsiveIec61850MonitorRuntimeFacade.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,17 @@ public Task<Iec61850ControlCapabilities> 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<Iec61850ControlCommandResult> 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.
Expand All @@ -223,7 +230,8 @@ public Task<Iec61850ControlCommandResult> ExecuteControlAsync(
request.ValueText);
try
{
return await _inner.ExecuteControlAsync(deviceId, request, token).ConfigureAwait(false);
return await _inner.ExecuteControlAsync(
deviceId, request, token, expectedAssociation).ConfigureAwait(false);
}
finally
{
Expand Down
74 changes: 74 additions & 0 deletions tests/ARSAS.Tests/M217P0ControlSingleFlightRecoveryTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Threading;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ARSAS.Tests;
Expand Down Expand Up @@ -242,4 +243,77 @@ 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<int>(
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<OperationCanceledException>(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);
}

}
Loading