Skip to content
Draft
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
30 changes: 30 additions & 0 deletions MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1793,5 +1793,35 @@
</Grid>
</Border>

<!-- Lightweight command shout: UI-only, does not intercept the operator
command buttons outside the visible notice; no dialog modality. -->
<Border x:Name="ControlShoutCard" Grid.RowSpan="3" Panel.ZIndex="90"
Visibility="Collapsed" HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="0,74,12,0" Width="400" MaxHeight="190"
CornerRadius="14" Background="#FFF8FAFD"
BorderBrush="#FFED9B95" BorderThickness="1.5" Padding="15,13">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="5" Opacity="0.26" Color="#152D48"/>
</Border.Effect>
<StackPanel>
<DockPanel LastChildFill="True">
<Border Width="27" Height="27" CornerRadius="8" Background="#FFFDE8E7"
DockPanel.Dock="Left" Margin="0,0,10,0">
<TextBlock Text="!" Foreground="#B42318" FontWeight="Bold" FontSize="17"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
<TextBlock x:Name="ControlShoutTitle" Text="Command rejected" FontSize="15"
FontWeight="Bold" Foreground="#B42318"
TextWrapping="Wrap" VerticalAlignment="Center"/>
</DockPanel>
<TextBlock x:Name="ControlShoutDetail" Margin="37,7,0,0" FontSize="12.5"
Foreground="#33465F" TextWrapping="Wrap" MaxHeight="102"
TextTrimming="CharacterEllipsis"
Text="Read the command rejection reason in Diagnostics."/>
<TextBlock Margin="37,8,0,0" FontSize="10.5" Foreground="#62758A"
Text="Details remain available in Diagnostics"/>
</StackPanel>
</Border>

</Grid>
</Window>
96 changes: 94 additions & 2 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ public partial class MainWindow : Window, INotifyPropertyChanged
private readonly Dictionary<string, List<SignalDefinition>> _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";
Expand Down Expand Up @@ -115,6 +121,11 @@ public MainWindow()
{
InitializeComponent();
DataContext = this;
_controlShoutTimer.Tick += (_, _) =>
{
_controlShoutTimer.Stop();
ControlShoutCard.Visibility = Visibility.Collapsed;
};

_uiFlushTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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}";
}
}

Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions Models/MonitorModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
127 changes: 127 additions & 0 deletions tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs
Original file line number Diff line number Diff line change
@@ -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"
};
}