Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f289554
feat(p0): add association-scoped control retirement and native drain …
masarray Oct 17, 2026
9087058
fix(p0): retire stuck MMS command sessions without blocking reconnect…
masarray Oct 17, 2026
66c0b99
test(p0): cover retired associations, cleanup and stale UI results
masarray Oct 17, 2026
8c9ac06
fix(p0): fence command UI results by exact MMS association
masarray Oct 17, 2026
3e8f832
test(p0): update ctlModel source contract for association-scoped flight
masarray Oct 17, 2026
050a65a
fix(p0): classify completed native wire-unknown outcomes as quarantin…
masarray Oct 17, 2026
e86cd86
fix(p0): quarantine completed native wire-unknown control results
masarray Oct 17, 2026
3f44916
fix(p0): quarantine completed native wire-unknown control results
masarray Oct 17, 2026
fdaed8e
fix(p0): quarantine completed native wire-unknown control results
masarray Oct 17, 2026
9d48394
fix(p0): propagate association retirement cancellation into native co…
masarray Oct 17, 2026
6a5c929
fix(p0): cancel queued native Command and ctlModel when association r…
masarray Oct 17, 2026
c6460b5
test(p0): require retirement cancellation before native drain
masarray Oct 17, 2026
aa0e63a
fix(p0): report UNKNOWN wire outcomes without incorrectly claiming co…
masarray Oct 17, 2026
4a0c209
fix(p0): forward association lifetime APIs through existing UI facade
masarray Oct 17, 2026
197ac6a
fix(p0): invoke retirement cancellation outside association admission…
masarray Oct 17, 2026
5f9a455
fix(p0): isolate native control signals from GUI association and upda…
masarray Oct 17, 2026
118f9d8
test(p0): import control model types for detached native command snap…
masarray Oct 17, 2026
4901991
test(p0): use syntactically valid LN control identity in snapshot reg…
masarray Oct 17, 2026
b1fdd73
fix(p0): quarantine association-loss and cancellation outcomes; corre…
masarray Oct 17, 2026
b22f0f8
test(p0): compare preflight signal display before and after native sn…
masarray Oct 17, 2026
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
36 changes: 33 additions & 3 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1500,7 +1500,8 @@ private bool RejectCommandWhenIedFlightUnavailable(
// Reuse existing CommandSignals state instead of mirroring a new
// runtime busy flag into the WPF view model.
if (device is null ||
!device.CommandSignals.Any(active => active.ControlCommandBusy))
(!device.CommandSignals.Any(active => active.ControlCommandBusy) &&
!_runtime.IsControlFlightUnavailable(device.DeviceId)))
return false;
const string reason =
"another IED command is still active or its wire outcome is unresolved";
Expand Down Expand Up @@ -1671,6 +1672,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo

DismissControlShout();
var clickStopwatch = Stopwatch.StartNew();
Guid dispatchedAssociation = Guid.Empty;
signal.ControlLastResult = $"Dispatching {claim.RequestedValue}…";
SetStatus($"{device.Name}: dispatching {signal.Name} = {claim.RequestedValue}…");
AddLog("INFO", device.Name,
Expand Down Expand Up @@ -1700,6 +1702,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo

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.");
dispatchedAssociation = _runtime.GetControlAssociationId(device.DeviceId);
var result = await _runtime.ExecuteControlAsync(
device.DeviceId,
new Iec61850ControlCommandRequest
Expand All @@ -1716,6 +1719,26 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
},
_applicationCancellation.Token);

if (!_runtime.IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation))
{
AddLog("WARN", device.Name,
$"CONTROL_STALE_RESULT_IGNORED: {signal.ObjectReference}; " +
"original association retired while native operation was pending.");
return;
}

if (Iec61850ControlWireOutcomePolicy.RequiresQuarantine(result.CompletionState))
{
signal.ControlLastResult =
"Control outcome UNKNOWN — native MMS result is inconclusive. " +
"Verify IED state and reconnect before another command.";
AddLog("ERROR", device.Name,
$"CONTROL_UI_NATIVE_UNKNOWN: {signal.ObjectReference}; " +
$"completion={result.CompletionState}; {result.Message}");
MarkDiagnosticAlert();
ShowControlShout(Iec61850ControlShout.FromUnknownWireOutcome());
return; // Do not publish ambiguous feedback as a process value.
}
if (!string.IsNullOrWhiteSpace(result.ControlModelText))
signal.ControlModelText = result.ControlModelText;
if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")
Expand All @@ -1738,6 +1761,8 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (OperationCanceledException)
{
if (dispatchedAssociation != Guid.Empty &&
!_runtime.IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation)) 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.");
}
Expand All @@ -1746,6 +1771,8 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
(ex.Message.Contains("in progress", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("unresolved", StringComparison.OrdinalIgnoreCase)))
{
if (dispatchedAssociation != Guid.Empty &&
!_runtime.IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation)) return;
signal.ControlLastResult = "Command NOT SENT: " + ex.Message;
AddLog("WARN", device.Name,
$"CONTROL_UI_LOCAL_GATE: {signal.ObjectReference}; {ex.Message}");
Expand All @@ -1756,18 +1783,21 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (TimeoutException ex)
{
if (dispatchedAssociation != Guid.Empty &&
!_runtime.IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation)) return;
signal.ControlLastResult =
"Control outcome UNKNOWN — MMS flight exceeded deadline. " +
"Verify IED process state and reconnect before another command.";
AddLog("ERROR", device.Name,
$"CONTROL_UI_TIMEOUT: {signal.ObjectReference}; {ex.Message}");
SetStatus($"{device.Name}: {signal.Name} control outcome UNKNOWN; check Diagnostics.");
MarkDiagnosticAlert();
ShowControlShout(Iec61850ControlShout.FromLocalRejection(
"Control timed out with UNKNOWN wire outcome. Verify the IED position and reconnect; do not blindly retry"));
ShowControlShout(Iec61850ControlShout.FromUnknownWireOutcome());
}
catch (Exception ex)
{
if (dispatchedAssociation != Guid.Empty &&
!_runtime.IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation)) 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
86 changes: 86 additions & 0 deletions Services/Iec61850ControlFlightAdmission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,89 @@ public static void Quarantine(ref int quarantine)
public static void Release(ref int active)
=> Interlocked.Exchange(ref active, 0);
}


/// <summary>
/// One physical MMS association's command lifetime. Retirement atomically
/// prevents admission and never equates WaitAsync cancellation with release
/// of the underlying native operation.
/// </summary>
public sealed class Iec61850ControlSessionLease
{
private readonly object _sync = new();
private TaskCompletionSource<bool>? _activeFlightReleased;
private readonly CancellationTokenSource _retirement = new();
private int _active;
private int _quarantine;
private int _retired;

// Linked into native operations so Stop can cancel even the narrow window
// between successful admission and native task creation.
public CancellationToken RetirementToken => _retirement.Token;
public bool IsActive => Volatile.Read(ref _active) != 0;
public bool IsRetired => Volatile.Read(ref _retired) != 0;
public bool IsUnavailable =>
IsRetired || Iec61850ControlFlightAdmission.IsUnavailable(ref _active, ref _quarantine);

public bool TryEnter()
{
lock (_sync)
{
if (_retired != 0 ||
!Iec61850ControlFlightAdmission.TryEnter(ref _active, ref _quarantine))
return false;
_activeFlightReleased = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
return true;
}
}

public void Quarantine()
=> Iec61850ControlFlightAdmission.Quarantine(ref _quarantine);

public void Release()
{
lock (_sync)
{
Iec61850ControlFlightAdmission.Release(ref _active);
_activeFlightReleased?.TrySetResult(true);
}
}

/// <summary>
/// Prevent future commands and return a drain which finishes only when
/// the original admitted native operation has actually released its lease.
/// </summary>
public Task Retire()
{
Task drain;
lock (_sync)
{
Volatile.Write(ref _retired, 1);
Quarantine();
drain = _active != 0
? _activeFlightReleased!.Task
: Task.CompletedTask;
}
// Never execute native cancellation callbacks under the admission lock.
// Retirement was already committed atomically before releasing it.
_retirement.Cancel();
return drain;
}
}


/// <summary>
/// A completed Task is not necessarily a definite wire result. Native services
/// can return UNKNOWN/timeout completion without throwing an exception.
/// </summary>
public static class Iec61850ControlWireOutcomePolicy
{
public static bool RequiresQuarantine(string? completionState) =>
!string.IsNullOrWhiteSpace(completionState) &&
(completionState.Contains("Unknown", StringComparison.OrdinalIgnoreCase) ||
completionState.Contains("TimedOut", StringComparison.OrdinalIgnoreCase) ||
completionState.Contains("Timeout", StringComparison.OrdinalIgnoreCase) ||
completionState.Equals("AssociationLost", StringComparison.OrdinalIgnoreCase) ||
completionState.Equals("Cancelled", StringComparison.OrdinalIgnoreCase));
}
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;
}
}
5 changes: 5 additions & 0 deletions Services/Iec61850ControlShout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ explanation.Confidence is "ConfirmedClient" or "LocalValidation"
Safe($"ARSAS blocked the request before dispatch: {reason}.", 175),
Iec61850ControlNoticeTone.Warning);

internal static Notice FromUnknownWireOutcome() => new(
"Command outcome UNKNOWN",
"MMS wire result is inconclusive. Verify actual IED state and reconnect before any new Operate.",
Iec61850ControlNoticeTone.Warning);

internal static Notice FromDemo(string feedback) => new(
"Simulation updated",
Safe($"Demo feedback: {feedback}. No command was sent to a physical IED.", 175),
Expand Down
Loading
Loading