diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 590d2378..c9a1a0ec 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -80,7 +80,7 @@ jobs:
- name: Publish portable x64
shell: powershell
- run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.6 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj"
+ run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.7 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj"
- name: Upload portable package
uses: actions/upload-artifact@v4
diff --git a/App.xaml b/App.xaml
index 29dca019..5079062a 100644
--- a/App.xaml
+++ b/App.xaml
@@ -259,6 +259,39 @@
+
+
-
-
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index b1e2d95f..02b9aa62 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -970,17 +970,20 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool
await Task.WhenAll(candidates.Select(async signal =>
{
await throttle.WaitAsync(_applicationCancellation.Token);
- signal.ControlIsBusy = true;
+ signal.ControlInspectionBusy = true;
try
{
var capabilities = await _runtime.InspectControlAsync(
device.DeviceId,
signal,
_applicationCancellation.Token);
- signal.ControlCurrentValue = capabilities.CurrentValue;
- signal.ControlLastResult = capabilities.SupportsOperate
- ? capabilities.ControlModelText
- : "Control unavailable";
+ if (!signal.ControlCommandBusy)
+ {
+ signal.ControlCurrentValue = capabilities.CurrentValue;
+ signal.ControlLastResult = capabilities.SupportsOperate
+ ? capabilities.ControlModelText
+ : "Control unavailable";
+ }
}
catch (OperationCanceledException)
{
@@ -988,11 +991,12 @@ await Task.WhenAll(candidates.Select(async signal =>
}
catch (Exception ex)
{
- signal.ControlLastResult = $"Status read failed: {ex.Message}";
+ if (!signal.ControlCommandBusy)
+ signal.ControlLastResult = $"Status read failed: {ex.Message}";
}
finally
{
- signal.ControlIsBusy = false;
+ signal.ControlInspectionBusy = false;
throttle.Release();
}
}));
@@ -1006,16 +1010,24 @@ private void ControlStageAction_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not SignalDefinition signal || !signal.IsPositionControl)
return;
- if (signal.ControlIsBusy || !signal.ControlSupportsOperate)
- return;
+ var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
var requestedValue = button.CommandParameter?.ToString()?.Trim() ?? string.Empty;
var actionLabel = button.Content?.ToString()?.Trim() ?? string.Empty;
- if (string.IsNullOrWhiteSpace(requestedValue) || string.IsNullOrWhiteSpace(actionLabel))
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Control confirmation stage click received: {signal.ObjectReference}; action={actionLabel}; value={requestedValue}; commandBusy={signal.ControlCommandBusy}; inspectionBusy={signal.ControlInspectionBusy}.");
+
+ if (!signal.TryStageControlConfirmation(requestedValue, actionLabel, out var rejectionReason))
+ {
+ signal.ControlLastResult = $"Command not staged: {rejectionReason}.";
+ AddLog("WARN", device?.Name ?? "IED",
+ $"Control confirmation stage rejected: {signal.ObjectReference}; reason={rejectionReason}.");
+ SetStatus($"{device?.Name ?? "IED"}: {signal.Name} command not staged — {rejectionReason}.");
return;
+ }
- signal.StageControlConfirmation(requestedValue, actionLabel);
- var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Control confirmation staged: {signal.ObjectReference}; action={actionLabel}; value={requestedValue}.");
SetStatus($"{device?.Name ?? "IED"}: review {signal.Name} — {signal.ControlPendingConfirmationLabel}, or Cancel.");
}
@@ -1023,12 +1035,23 @@ private async void ControlConfirmAction_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not SignalDefinition signal)
return;
- if (!signal.ControlCanConfirm || string.IsNullOrWhiteSpace(signal.ControlPendingValue))
+
+ var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Confirm click received: {signal.ObjectReference}; pending={signal.ControlConfirmationPending}; action={signal.ControlPendingAction}; value={signal.ControlPendingValue}; commandBusy={signal.ControlCommandBusy}; inspectionBusy={signal.ControlInspectionBusy}.");
+
+ if (!signal.TryClaimControlConfirmation(out var claim, out var rejectionReason) || claim == null)
+ {
+ signal.ControlLastResult = $"Confirmation rejected: {rejectionReason}.";
+ AddLog("WARN", device?.Name ?? "IED",
+ $"Confirm rejected: {signal.ObjectReference}; reason={rejectionReason}.");
+ SetStatus($"{device?.Name ?? "IED"}: {signal.Name} confirmation rejected — {rejectionReason}.");
return;
+ }
- var requestedValue = signal.ControlPendingValue;
- signal.ClearControlConfirmation();
- await ExecuteQuickControlAsync(signal, requestedValue);
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Confirm accepted: {signal.ObjectReference}; sequence={claim.Sequence}; action={claim.ActionLabel}; value={claim.RequestedValue}.");
+ await ExecuteClaimedControlAsync(signal, claim);
}
private void ControlCancelAction_Click(object sender, RoutedEventArgs e)
@@ -1039,6 +1062,8 @@ private void ControlCancelAction_Click(object sender, RoutedEventArgs e)
var action = signal.ControlPendingAction;
signal.ClearControlConfirmation();
var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ AddLog("INFO", device?.Name ?? "IED",
+ $"Control confirmation cancelled: {signal.ObjectReference}; action={action}.");
SetStatus($"{device?.Name ?? "IED"}: {signal.Name} {action} cancelled before dispatch.");
}
@@ -1056,27 +1081,37 @@ private async void ControlQuickAction_Click(object sender, RoutedEventArgs e)
return;
}
- await ExecuteQuickControlAsync(signal, requestedValue);
+ var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
+ if (!signal.TryBeginDirectControlCommand(
+ requestedValue,
+ button.Content?.ToString()?.Trim() ?? requestedValue,
+ out var claim,
+ out var rejectionReason) || claim == null)
+ {
+ signal.ControlLastResult = $"Command rejected: {rejectionReason}.";
+ AddLog("WARN", device?.Name ?? "IED",
+ $"Direct control rejected: {signal.ObjectReference}; reason={rejectionReason}.");
+ SetStatus($"{device?.Name ?? "IED"}: {signal.Name} command rejected — {rejectionReason}.");
+ return;
+ }
+
+ await ExecuteClaimedControlAsync(signal, claim);
}
- private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requestedValue)
+ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCommandClaim claim)
{
var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
if (device == null)
- return;
-
- if (signal.ControlIsBusy)
{
- SetStatus($"{device.Name}: {signal.Name} command is already in progress.");
+ signal.CompleteControlCommand(claim);
return;
}
var clickStopwatch = Stopwatch.StartNew();
- signal.ControlIsBusy = true;
- signal.ControlLastResult = $"Dispatching {requestedValue}…";
- SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…");
+ signal.ControlLastResult = $"Dispatching {claim.RequestedValue}…";
+ SetStatus($"{device.Name}: dispatching {signal.Name} = {claim.RequestedValue}…");
AddLog("INFO", device.Name,
- $"Control click accepted: {signal.ObjectReference} value={requestedValue}; test={signal.ControlTestMode}; interlock={signal.ControlInterlockCheck}; synchro={signal.ControlSynchroCheck}.");
+ $"Dispatch ownership acquired: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}; test={signal.ControlTestMode}; interlock={signal.ControlInterlockCheck}; synchro={signal.ControlSynchroCheck}.");
await Dispatcher.Yield(DispatcherPriority.Render);
try
@@ -1091,15 +1126,14 @@ private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requ
return;
}
- // ExecuteControlAsync owns one live status preflight and the complete control
- // sequence. The old UI path performed a second status read before this call,
- // which added queue latency and created a stale-value race.
+ AddLog("INFO", device.Name,
+ $"MMS command submitted: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}.");
var result = await _runtime.ExecuteControlAsync(
device.DeviceId,
new Iec61850ControlCommandRequest
{
Signal = signal,
- ValueText = requestedValue,
+ ValueText = claim.RequestedValue,
InterlockCheck = signal.ControlInterlockCheck,
SynchroCheck = signal.ControlSynchroCheck,
TestMode = signal.ControlTestMode,
@@ -1119,7 +1153,7 @@ private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requ
SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}");
clickStopwatch.Stop();
AddLog(result.IsSuccess ? "INFO" : "WARN", device.Name,
- $"Control UI timing: {signal.ObjectReference}; click-to-result={clickStopwatch.Elapsed.TotalMilliseconds:0.###} ms; engine-total={result.TotalElapsedText}; serviceAccepted={result.ServiceAccepted}; stage={result.Stage}.");
+ $"Control UI timing: {signal.ObjectReference}; sequence={claim.Sequence}; click-to-result={clickStopwatch.Elapsed.TotalMilliseconds:0.###} ms; engine-total={result.TotalElapsedText}; serviceAccepted={result.ServiceAccepted}; stage={result.Stage}.");
}
catch (OperationCanceledException)
{
@@ -1135,8 +1169,12 @@ private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requ
}
finally
{
- signal.ControlIsBusy = false;
- signal.ClearControlConfirmation();
+ if (!signal.CompleteControlCommand(claim))
+ {
+ AddLog("ERROR", device.Name,
+ $"Control ownership release mismatch: {signal.ObjectReference}; sequence={claim.Sequence}.");
+ MarkDiagnosticAlert();
+ }
}
}
diff --git a/Models/SignalDefinition.cs b/Models/SignalDefinition.cs
index bbf9e6b2..120f0b35 100644
--- a/Models/SignalDefinition.cs
+++ b/Models/SignalDefinition.cs
@@ -2,6 +2,8 @@
namespace ArIED61850Tester.Models;
+public sealed record ControlCommandClaim(long Sequence, string RequestedValue, string ActionLabel);
+
public class SignalDefinition : ObservableObject
{
private bool _isSelected;
@@ -24,7 +26,11 @@ public class SignalDefinition : ObservableObject
private string? _deferredControlCurrentValue;
private string _controlSetPointText = string.Empty;
private string _controlLastResult = string.Empty;
- private bool _controlIsBusy;
+ private readonly object _controlCommandSync = new();
+ private bool _controlInspectionBusy;
+ private bool _controlCommandBusy;
+ private long _nextControlCommandSequence;
+ private long _activeControlCommandSequence;
private bool _controlInterlockCheck = true;
private bool _controlSynchroCheck;
private bool _controlTestMode;
@@ -133,7 +139,7 @@ public string ControlCurrentValue
set
{
var normalized = NormalizeControlDisplayValue(value);
- if (ControlIsBusy)
+ if (ControlCommandBusy)
{
_deferredControlCurrentValue = normalized;
return;
@@ -167,31 +173,20 @@ public string ControlLastResult
Set(ref _controlLastResult, normalized);
}
}
- public bool ControlIsBusy
+ public bool ControlInspectionBusy
{
- get => _controlIsBusy;
+ get => _controlInspectionBusy;
set
{
- if (!Set(ref _controlIsBusy, value))
- return;
-
- Raise(nameof(ControlCanConfirm));
-
- if (value)
- {
- _deferredControlCurrentValue = null;
+ if (!Set(ref _controlInspectionBusy, value))
return;
- }
-
- if (_deferredControlCurrentValue != null)
- {
- var deferred = _deferredControlCurrentValue;
- _deferredControlCurrentValue = null;
- ApplyControlCurrentValue(deferred);
- }
+ Raise(nameof(ControlIsBusy));
}
}
+ public bool ControlCommandBusy => _controlCommandBusy;
+ public bool ControlIsBusy => _controlInspectionBusy || _controlCommandBusy;
+
public bool ControlInterlockCheck
{
get => _controlInterlockCheck;
@@ -215,28 +210,178 @@ public bool ControlTestMode
public string ControlPendingAction => _controlPendingAction;
public string ControlPendingConfirmationLabel =>
string.IsNullOrWhiteSpace(_controlPendingAction) ? "Confirm command" : $"Confirm {_controlPendingAction}";
- public bool ControlCanConfirm => _controlConfirmationPending && ControlSupportsOperate && !ControlIsBusy;
+ public bool ControlCanConfirm => _controlConfirmationPending && ControlSupportsOperate && !ControlCommandBusy;
- public void StageControlConfirmation(string requestedValue, string actionLabel)
+ public bool TryStageControlConfirmation(string requestedValue, string actionLabel, out string rejectionReason)
{
var normalizedValue = requestedValue?.Trim() ?? string.Empty;
- if (string.IsNullOrWhiteSpace(normalizedValue))
- return;
+ var normalizedAction = actionLabel?.Trim() ?? string.Empty;
+ lock (_controlCommandSync)
+ {
+ if (!ControlSupportsOperate)
+ {
+ rejectionReason = "the live control model does not permit operation";
+ return false;
+ }
+ if (_controlCommandBusy)
+ {
+ rejectionReason = "another command already owns this control object";
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(normalizedValue) || string.IsNullOrWhiteSpace(normalizedAction))
+ {
+ rejectionReason = "the requested command value or action is empty";
+ return false;
+ }
+
+ _controlPendingValue = normalizedValue;
+ _controlPendingAction = normalizedAction;
+ _controlConfirmationPending = true;
+ }
+
+ Raise(nameof(ControlPendingValue));
+ Raise(nameof(ControlPendingAction));
+ Raise(nameof(ControlPendingConfirmationLabel));
+ Raise(nameof(ControlConfirmationPending));
+ Raise(nameof(ControlCanConfirm));
+ rejectionReason = string.Empty;
+ return true;
+ }
+
+ public bool TryClaimControlConfirmation(out ControlCommandClaim? claim, out string rejectionReason)
+ {
+ lock (_controlCommandSync)
+ {
+ if (!_controlConfirmationPending)
+ {
+ claim = null;
+ rejectionReason = "no pending confirmation exists";
+ return false;
+ }
+ if (!ControlSupportsOperate)
+ {
+ claim = null;
+ rejectionReason = "the live control model no longer permits operation";
+ return false;
+ }
+ if (_controlCommandBusy)
+ {
+ claim = null;
+ rejectionReason = "another command already owns this control object";
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(_controlPendingValue))
+ {
+ claim = null;
+ rejectionReason = "the pending command value is empty";
+ return false;
+ }
- Set(ref _controlPendingValue, normalizedValue, nameof(ControlPendingValue));
- if (Set(ref _controlPendingAction, actionLabel?.Trim() ?? string.Empty, nameof(ControlPendingAction)))
- Raise(nameof(ControlPendingConfirmationLabel));
- if (Set(ref _controlConfirmationPending, true, nameof(ControlConfirmationPending)))
- Raise(nameof(ControlCanConfirm));
+ var sequence = ++_nextControlCommandSequence;
+ _activeControlCommandSequence = sequence;
+ _controlCommandBusy = true;
+ claim = new ControlCommandClaim(sequence, _controlPendingValue, _controlPendingAction);
+ _controlPendingValue = string.Empty;
+ _controlPendingAction = string.Empty;
+ _controlConfirmationPending = false;
+ }
+
+ PublishCommandClaimed();
+ rejectionReason = string.Empty;
+ return true;
+ }
+
+ public bool TryBeginDirectControlCommand(
+ string requestedValue,
+ string actionLabel,
+ out ControlCommandClaim? claim,
+ out string rejectionReason)
+ {
+ var normalizedValue = requestedValue?.Trim() ?? string.Empty;
+ var normalizedAction = actionLabel?.Trim() ?? normalizedValue;
+ lock (_controlCommandSync)
+ {
+ if (!ControlSupportsOperate)
+ {
+ claim = null;
+ rejectionReason = "the live control model does not permit operation";
+ return false;
+ }
+ if (_controlCommandBusy)
+ {
+ claim = null;
+ rejectionReason = "another command already owns this control object";
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(normalizedValue))
+ {
+ claim = null;
+ rejectionReason = "the requested command value is empty";
+ return false;
+ }
+
+ var sequence = ++_nextControlCommandSequence;
+ _activeControlCommandSequence = sequence;
+ _controlCommandBusy = true;
+ claim = new ControlCommandClaim(sequence, normalizedValue, normalizedAction);
+ _controlPendingValue = string.Empty;
+ _controlPendingAction = string.Empty;
+ _controlConfirmationPending = false;
+ }
+
+ PublishCommandClaimed();
+ rejectionReason = string.Empty;
+ return true;
+ }
+
+ public bool CompleteControlCommand(ControlCommandClaim claim)
+ {
+ string? deferred = null;
+ lock (_controlCommandSync)
+ {
+ if (!_controlCommandBusy || _activeControlCommandSequence != claim.Sequence)
+ return false;
+
+ _activeControlCommandSequence = 0;
+ _controlCommandBusy = false;
+ deferred = _deferredControlCurrentValue;
+ _deferredControlCurrentValue = null;
+ }
+
+ Raise(nameof(ControlCommandBusy));
+ Raise(nameof(ControlIsBusy));
+ Raise(nameof(ControlCanConfirm));
+ if (deferred != null)
+ ApplyControlCurrentValue(deferred);
+ return true;
}
public void ClearControlConfirmation()
{
- Set(ref _controlPendingValue, string.Empty, nameof(ControlPendingValue));
- if (Set(ref _controlPendingAction, string.Empty, nameof(ControlPendingAction)))
- Raise(nameof(ControlPendingConfirmationLabel));
- if (Set(ref _controlConfirmationPending, false, nameof(ControlConfirmationPending)))
- Raise(nameof(ControlCanConfirm));
+ lock (_controlCommandSync)
+ {
+ _controlPendingValue = string.Empty;
+ _controlPendingAction = string.Empty;
+ _controlConfirmationPending = false;
+ }
+
+ Raise(nameof(ControlPendingValue));
+ Raise(nameof(ControlPendingAction));
+ Raise(nameof(ControlPendingConfirmationLabel));
+ Raise(nameof(ControlConfirmationPending));
+ Raise(nameof(ControlCanConfirm));
+ }
+
+ private void PublishCommandClaimed()
+ {
+ _deferredControlCurrentValue = null;
+ Raise(nameof(ControlPendingValue));
+ Raise(nameof(ControlPendingAction));
+ Raise(nameof(ControlPendingConfirmationLabel));
+ Raise(nameof(ControlConfirmationPending));
+ Raise(nameof(ControlCommandBusy));
+ Raise(nameof(ControlIsBusy));
+ Raise(nameof(ControlCanConfirm));
}
private bool CanExposeControlActions => !_controlModelResolved || ControlSupportsOperate;
diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1
index 6b8bc160..ab55fa8d 100644
--- a/scripts/publish-windows-portable.ps1
+++ b/scripts/publish-windows-portable.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.6.6",
+ [string]$Version = "1.6.7",
[string]$Runtime = "win-x64",
[bool]$SingleFile = $true,
[bool]$SelfContained = $true,
@@ -23,7 +23,7 @@ if ($normalizedVersion.StartsWith("v", [System.StringComparison]::OrdinalIgnoreC
$normalizedVersion = $normalizedVersion.Substring(1)
}
if ($normalizedVersion -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') {
- throw "Invalid version '$Version'. Use a value such as 1.6.6 or v1.6.6."
+ throw "Invalid version '$Version'. Use a value such as 1.6.7 or v1.6.7."
}
$outputRoot = Join-Path $root "dist"