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" + }; +}