diff --git a/MainWindow.xaml b/MainWindow.xaml
index cfe5d6a27..6482c8cfc 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -1793,5 +1793,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 5f8139ff5..964567086 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -35,6 +35,12 @@ public partial class MainWindow : Window, INotifyPropertyChanged
private readonly Dictionary> _controlFeedbackIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly DispatcherTimer _uiFlushTimer;
private readonly DispatcherTimer _progressAnimationTimer;
+ // Shared, resettable timer: concurrent commands replace the same toast
+ // without spawning timers/tasks for every fast-control outcome.
+ private readonly DispatcherTimer _controlShoutTimer = new(DispatcherPriority.Normal)
+ {
+ Interval = TimeSpan.FromSeconds(5)
+ };
private Iec61850MonitorDevice? _selectedDevice;
private string _newDeviceIp = "192.168.1.10";
private string _newDevicePort = "102";
@@ -115,6 +121,11 @@ public MainWindow()
{
InitializeComponent();
DataContext = this;
+ _controlShoutTimer.Tick += (_, _) =>
+ {
+ _controlShoutTimer.Stop();
+ ControlShoutCard.Visibility = Visibility.Collapsed;
+ };
_uiFlushTimer = new DispatcherTimer(DispatcherPriority.Background)
{
@@ -1538,6 +1549,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
signal.ControlCurrentValue = result.FeedbackValue;
signal.ControlLastResult = BuildQuickControlResult(result);
+ if (!result.IsSuccess)
+ {
+ var shout = BuildControlShout(result);
+ ShowControlShout(shout.Title, shout.Detail);
+ }
SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}");
clickStopwatch.Stop();
AddLog(result.IsSuccess ? "INFO" : "WARN", device.Name,
@@ -1551,6 +1567,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
catch (Exception ex)
{
signal.ControlLastResult = $"Command failed: {ex.Message}";
+ ShowControlShout("Command failed", "The operation could not complete. Check Diagnostics for the technical reason.");
AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}");
SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}");
MarkDiagnosticAlert();
@@ -1585,8 +1602,8 @@ private static string BuildQuickControlResult(Iec61850ControlCommandResult resul
var stopped = stage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent
? " • Operate NOT sent"
: string.Empty;
- var cause = string.IsNullOrWhiteSpace(result.AddCause) ? string.Empty : $" • AddCause={result.AddCause}";
- return $"IED REJECTED {stage}: {result.Message}{cause}{stopped}{suffix}";
+ var detail = BuildControlRejectionDetail(result);
+ return $"IED REJECTED {stage}: {detail}{stopped}{suffix}";
}
}
@@ -1595,6 +1612,81 @@ private static string BuildQuickControlResult(Iec61850ControlCommandResult resul
: $"{result.Stage}: {result.Message}{suffix}";
}
+ // Report only the actual IEC 61850/MMS evidence. F650 can reject an
+ // Operate with object-access-denied while in Local, but the same MMS
+ // service error is also possible for other causes. Never fabricate the
+ // remote/interlock/auth state unless the IED supplies AddCause.
+ internal static string BuildControlRejectionDetail(Iec61850ControlCommandResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ var actual = string.IsNullOrWhiteSpace(result.Message)
+ ? "The IED did not provide a detailed rejection message."
+ : result.Message.Trim();
+ var error = string.IsNullOrWhiteSpace(result.ControlError)
+ ? string.Empty : $" • ControlError={result.ControlError.Trim()}";
+ var cause = string.IsNullOrWhiteSpace(result.AddCause)
+ ? string.Empty : $" • AddCause={result.AddCause.Trim()}";
+ var hasExplicitReason = !string.IsNullOrWhiteSpace(result.ControlError) ||
+ !string.IsNullOrWhiteSpace(result.AddCause);
+ // A control refusal is not proof of any particular relay state.
+ var guidance = !hasExplicitReason &&
+ (actual.Contains("object-access-denied", StringComparison.OrdinalIgnoreCase) ||
+ actual.Contains("access denied", StringComparison.OrdinalIgnoreCase))
+ ? " • IED did not disclose the cause; check Local/Remote, command authorization and interlocks on the device."
+ : string.Empty;
+ return actual + error + cause + guidance;
+ }
+
+ internal static (string Title, string Detail) BuildControlShout(Iec61850ControlCommandResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ var cause = (result.AddCause ?? string.Empty).Trim();
+ var normalized = cause.ToLowerInvariant().Replace('_', '-').Replace(' ', '-');
+ var title = normalized.Contains("interlock", StringComparison.Ordinal)
+ ? "Command blocked by interlock"
+ : normalized.Contains("synchro", StringComparison.Ordinal)
+ ? "Command blocked by synchrocheck"
+ : normalized.Contains("authority", StringComparison.Ordinal) ||
+ normalized.Contains("authorization", StringComparison.Ordinal) ||
+ normalized.Contains("access-violation", StringComparison.Ordinal)
+ ? "Command authorization denied"
+ : normalized.Contains("mode", StringComparison.Ordinal)
+ ? "Command blocked by operating mode"
+ : "Command rejected by IED";
+
+ // Even when the engineer knows the hardware was in Local, generic MMS
+ // access-denied does not encode that fact. Avoid a false positive:
+ // show Local/Remote as a troubleshooting hint, not proven rejection.
+ var evidence = BuildControlRejectionDetail(result);
+ var detail = title == "Command rejected by IED" &&
+ evidence.Contains("object-access-denied", StringComparison.OrdinalIgnoreCase)
+ ? "IED refused the command (MMS access denied). Check BCU Local/Remote and permissions; see Diagnostics."
+ : title == "Command blocked by interlock"
+ ? "IED reports interlocking protection. Verify the permissives before retrying."
+ : title == "Command blocked by synchrocheck"
+ ? "IED reports synchrocheck protection. Check synchronization conditions."
+ : title == "Command authorization denied"
+ ? "IED reports insufficient control authority. Check the operator access level."
+ : title == "Command blocked by operating mode"
+ ? "IED reports an operating-mode restriction. Check Local/Remote and control mode."
+ : evidence;
+ return (title, detail.Length <= 220 ? detail : detail[..220] + "…");
+ }
+
+ private void ShowControlShout(string title, string detail)
+ {
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(() => ShowControlShout(title, detail));
+ return;
+ }
+ _controlShoutTimer.Stop();
+ ControlShoutTitle.Text = title;
+ ControlShoutDetail.Text = detail;
+ ControlShoutCard.Visibility = Visibility.Visible;
+ _controlShoutTimer.Start();
+ }
+
private async void ControlDetails_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not SignalDefinition signal || !signal.IsControlSignal)
diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs
index 3269c0cd1..a932011c5 100644
--- a/Models/MonitorModels.cs
+++ b/Models/MonitorModels.cs
@@ -596,6 +596,15 @@ private void RefreshCommandSignals()
.Where(signal => signal.IsSelected && signal.IsValidControlObject)
.Where(signal => signal.ControlModelResolved && signal.ControlSupportsOperate)
.Where(signal => !signal.IsGenericControl)
+ // One control object can belong to multiple IEC 61850 DataSets.
+ // Keep the source rows intact for reporting, one action per target.
+ .GroupBy(signal => (signal.ObjectReference ?? string.Empty).Trim(),
+ StringComparer.OrdinalIgnoreCase)
+ .Select(group => group
+ .OrderByDescending(signal => !string.IsNullOrWhiteSpace(signal.ControlStatusReference))
+ .ThenBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(signal => signal.DisplayReference, StringComparer.OrdinalIgnoreCase)
+ .First())
.OrderBy(signal => signal.SortPriority)
.ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase)
.ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase)
diff --git a/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs b/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs
new file mode 100644
index 000000000..4c78d5854
--- /dev/null
+++ b/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs
@@ -0,0 +1,127 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester;
+
+namespace ARSAS.Tests;
+
+public sealed class CommandIdentityRejectionP76FTests
+{
+ [Fact]
+ public void CommandDock_DeduplicatesDifferentDataSetsByExactControlObject()
+ {
+ var device = new Iec61850MonitorDevice();
+ var first = Command("BCUGEF650/CSWI1.Pos", "BCUGEF650/LLN0.CSWI");
+ var second = Command("BCUGEF650/CSWI1.Pos", "BCUGEF650/LLN0.MMXU_SOGI1");
+ var third = Command("BCUGEF650/CSWI10.Pos", "BCUGEF650/LLN0.CSWI");
+ device.Signals.Add(first);
+ device.Signals.Add(second);
+ device.Signals.Add(third);
+ device.RefreshCommandSignalProjection();
+ Assert.Equal(2, device.CommandSignals.Count);
+ Assert.Contains(device.CommandSignals, s => s.ObjectReference == "BCUGEF650/CSWI1.Pos");
+ Assert.Contains(device.CommandSignals, s => s.ObjectReference == "BCUGEF650/CSWI10.Pos");
+ Assert.Equal(3, device.Signals.Count); // reporting membership unchanged
+ }
+
+ [Fact]
+ public void CommandDock_DoesNotMergeDifferentLDsOrIEDs()
+ {
+ var device = new Iec61850MonitorDevice();
+ device.Signals.Add(Command("BCUGEF650/CSWI1.Pos", "a"));
+ device.Signals.Add(Command("BCUGEOTHER/CSWI1.Pos", "b"));
+ device.RefreshCommandSignalProjection();
+ Assert.Equal(2, device.CommandSignals.Count);
+ }
+
+ [Fact]
+ public void DeniedMmsWithoutAddCause_DoesNotInventLocalInterlockOrAuthorization()
+ {
+ var result = new Iec61850ControlCommandResult
+ {
+ Message = "MMS Confirmed-Write rejected: item[0]=object-access-denied (3)."
+ };
+ var message = MainWindow.BuildControlRejectionDetail(result);
+ Assert.Contains("object-access-denied", message);
+ Assert.Contains("check Local/Remote", message);
+ Assert.Contains("did not disclose the cause", message);
+ Assert.DoesNotContain("AddCause=", message);
+ Assert.DoesNotContain("ControlError=", message);
+ }
+
+ [Fact]
+ public void RealAddCause_IsPreservedNotOverriddenBySpeculativeLocalDiagnosis()
+ {
+ var result = new Iec61850ControlCommandResult
+ {
+ Message = "Control rejected by IED",
+ AddCause = "blocked-by-interlocking",
+ ControlError = "1"
+ };
+ var message = MainWindow.BuildControlRejectionDetail(result);
+ Assert.Contains("AddCause=blocked-by-interlocking", message);
+ Assert.Contains("ControlError=1", message);
+ Assert.DoesNotContain("check Local/Remote", message);
+ }
+
+ [Theory]
+ [InlineData("blocked-by-interlocking", "Command blocked by interlock")]
+ [InlineData("blocked-by-synchrocheck", "Command blocked by synchrocheck")]
+ [InlineData("no-access-authority", "Command authorization denied")]
+ [InlineData("blocked-by-mode", "Command blocked by operating mode")]
+ public void Toast_UsesRealIedAddCauseWhenAvailable(string addCause, string expected)
+ {
+ var result = new Iec61850ControlCommandResult
+ {
+ Message = "Control service rejected",
+ AddCause = addCause
+ };
+ var toast = MainWindow.BuildControlShout(result);
+ Assert.Equal(expected, toast.Title);
+ }
+
+ [Fact]
+ public void Toast_GenericMmsAccessDeniedDoesNotClaimLocalIsProven()
+ {
+ var result = new Iec61850ControlCommandResult
+ {
+ Message = "MMS Confirmed-Write rejected: object-access-denied (3)"
+ };
+ var toast = MainWindow.BuildControlShout(result);
+ Assert.Equal("Command rejected by IED", toast.Title);
+ Assert.Contains("Check BCU Local/Remote", toast.Detail);
+ Assert.DoesNotContain("confirmed local", toast.Detail, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Toast_XamlIsNonModalAndTimerAutoDismisses()
+ {
+ var xaml = Read("MainWindow.xaml");
+ var code = Read("MainWindow.xaml.cs");
+ Assert.Contains("x:Name=\"ControlShoutCard\"", xaml);
+ Assert.Contains("Panel.ZIndex=\"90\"", xaml);
+ Assert.Contains("Interval = TimeSpan.FromSeconds(5)", code);
+ Assert.Contains("ControlShoutCard.Visibility = Visibility.Collapsed", code);
+ }
+
+ private static string Read(string path)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ var file = Path.Combine(directory.FullName, path);
+ if (File.Exists(file)) return File.ReadAllText(file);
+ directory = directory.Parent;
+ }
+ throw new FileNotFoundException(path);
+ }
+
+ private static SignalDefinition Command(string reference, string dataset) => new()
+ {
+ ObjectReference = reference,
+ DisplayReference = reference,
+ DataSetReference = dataset,
+ IsControlSignal = true,
+ IsSelected = true,
+ ControlCdc = "DPC",
+ ControlModelText = "Direct · Normal security"
+ };
+}