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>
46 changes: 34 additions & 12 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;
// One timer per window: concurrent negative command results replace the
// current notice without spawning per-result tasks or blocking WPF input.
private readonly DispatcherTimer _controlShoutTimer = new(DispatcherPriority.Normal)
{
Interval = TimeSpan.FromSeconds(8)
};
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 @@ -1543,21 +1554,14 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
AddLog(result.IsSuccess ? "INFO" : "WARN", device.Name,
$"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}.");

// A rejected physical control needs a prominent operator-visible
// explanation; the status bar and Diagnostics alone are insufficient.
// Never present a generic MMS access-denied as proven Local/Remote.
// Failure must be visible without a modal dialog blocking unrelated
// IED operations. Service/AddCause evidence stays in diagnostics and
// the per-command result; the notice never invents Local/Remote.
if (!result.IsSuccess)
{
var explanation = Iec61850ControlFailureReason.Explain(result);
MarkDiagnosticAlert();
MessageBox.Show(this,
$"{explanation.Summary}\n\nEvidence: {explanation.Evidence}\n" +
$"Certainty: {explanation.Confidence}\n\nCheck: {explanation.Checks}\n\n" +
"The exact MMS response and AddCause (if supplied) remain in Diagnostics.",
result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase)
? $"Command Not Sent — {signal.ObjectReference}"
: $"IED Command Failed — {signal.ObjectReference}",
MessageBoxButton.OK, MessageBoxImage.Warning);
var notice = Iec61850ControlShout.FromResult(result);
ShowControlShout(notice.Title, notice.Detail);
}
}
catch (OperationCanceledException)
Expand All @@ -1571,6 +1575,8 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}");
SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}");
MarkDiagnosticAlert();
var notice = Iec61850ControlShout.FromUnexpectedFailure();
ShowControlShout(notice.Title, notice.Detail);
}
finally
{
Expand Down Expand Up @@ -1614,6 +1620,22 @@ private static string BuildQuickControlResult(Iec61850ControlCommandResult resul
: $"{result.Stage}: {Iec61850ControlFailureReason.Explain(result).Summary} • {result.Message}{suffix}";
}

private void ShowControlShout(string title, string detail)
{
// UI ownership is confined to WPF Dispatcher. The runtime's atomic
// per-target command admission remains independent of display timing.
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
50 changes: 50 additions & 0 deletions Services/Iec61850ControlShout.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

/// <summary>
/// Evidence-graded, bounded, human-readable operator notification.
/// ARIEC61850 remains the sole authority for MMS/AddCause and for control
/// acceptance. No control write or status inference is performed here.
/// </summary>
internal static class Iec61850ControlShout
{
internal sealed record Notice(string Title, string Detail);

internal static Notice FromResult(Iec61850ControlCommandResult result)
{
ArgumentNullException.ThrowIfNull(result);
var explanation = Iec61850ControlFailureReason.Explain(result);
if (result.IsSuccess)
return new("Command succeeded", "The IED accepted the command.");

var title = explanation.Confidence switch
{
"ConfirmedClient" => "Command not sent",
"IEDExplicitAddCause" => explanation.Summary,
"MmsServiceOnly" => "IED rejected command — reason unspecified",
"TerminationOnly" => "IED reported negative command termination",
_ => "Command failed — reason unavailable"
};

// The context is guidance, NOT a confirmed diagnosis for generic MMS
// object-access-denied. A local BCU position must be substantiated by
// distinct IED status/LastApplError evidence, never guessed here.
var detail = explanation.Confidence == "MmsServiceOnly"
? "MMS access denied; exact cause not supplied. Check BCU Local/Remote, authorization and interlocks."
: $"Evidence: {explanation.Evidence}. Check: {explanation.Checks}.";
return new(Safe(title, 95), Safe(detail, 250));
}

internal static Notice FromUnexpectedFailure() =>
new("Command could not complete",
"A local or communication error occurred. The exact exception is recorded in Diagnostics.");

private static string Safe(string? value, int max)
{
// No CR/LF or control-character injection into the command overlay.
var flattened = new string((value ?? string.Empty).Select(ch =>
char.IsControl(ch) ? ' ' : ch).ToArray()).Trim();
return flattened.Length <= max ? flattened : flattened[..max] + "…";
}
}
9 changes: 5 additions & 4 deletions tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,11 @@ public void FailedCommand_HasVisibleOperatorWarning_OnlyOnFailure()
{
var main = Read("MainWindow.xaml.cs");
Assert.Contains("if (!result.IsSuccess)", main, StringComparison.Ordinal);
Assert.Contains("IED Command Failed —", main, StringComparison.Ordinal);
Assert.Contains("MessageBoxImage.Warning", main, StringComparison.Ordinal);
Assert.Contains("explanation.Confidence", main, StringComparison.Ordinal);
Assert.Contains("The exact MMS response and AddCause", main, StringComparison.Ordinal);
Assert.Contains("Iec61850ControlShout.FromResult(result)", main, StringComparison.Ordinal);
Assert.Contains("ShowControlShout(notice.Title, notice.Detail)", main, StringComparison.Ordinal);
Assert.Contains("MarkDiagnosticAlert()", main, StringComparison.Ordinal);
Assert.DoesNotContain("IED Command Failed —", main, StringComparison.Ordinal);
Assert.DoesNotContain("MessageBoxImage.Warning", main, StringComparison.Ordinal);
}

[Fact]
Expand Down
119 changes: 119 additions & 0 deletions tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ARSAS.Tests;

/// <summary>
/// Cross-workstream G1/F650 acceptance: canonical target and non-blocking
/// evidence-graded rejection notification must coexist in the same build.
/// </summary>
public sealed class F650CommandShoutIntegrationTests
{
[Fact]
public void MmsDenied_StatesNoExactCause_AndSuggestsLocalRemoteCheck()
{
var result = new Iec61850ControlCommandResult
{
Message = "MMS Confirmed-Write rejected: item[0]=object-access-denied (3)",
CompletionState = "Rejected"
};
var notice = Iec61850ControlShout.FromResult(result);
Assert.Contains("reason unspecified", notice.Title, StringComparison.Ordinal);
Assert.Contains("Check BCU Local/Remote", notice.Detail, StringComparison.Ordinal);
Assert.DoesNotContain("IED confirmed Local", notice.Detail, StringComparison.OrdinalIgnoreCase);
}

[Theory]
[InlineData("blocked-by-interlocking", "interlocking")]
[InlineData("blocked-by-synchrocheck", "synchrocheck")]
[InlineData("blocked-by-mode", "control mode")]
[InlineData("no-access-authority", "access authority")]
public void ExplicitIedAddCause_IsShoutedAsEngineEvidence(string addCause, string message)
{
var result = new Iec61850ControlCommandResult
{
AddCause = addCause,
Message = "IED refused operation",
CompletionState = "Rejected"
};
var notice = Iec61850ControlShout.FromResult(result);
Assert.Contains(message, notice.Title, StringComparison.OrdinalIgnoreCase);
Assert.Contains("LastApplError AddCause", notice.Detail, StringComparison.Ordinal);
Assert.DoesNotContain("exact cause not supplied", notice.Detail, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void NoWireTransmission_ReportsNotSentRatherThanIedRejected()
{
var result = new Iec61850ControlCommandResult { CompletionState = "NotSent" };
var notice = Iec61850ControlShout.FromResult(result);
Assert.Equal("Command not sent", notice.Title);
Assert.Contains("Client rejected before MMS Operate", notice.Detail, StringComparison.Ordinal);
}

[Fact]
public void Shout_FromSuccess_DoesNotClaimControlRejection()
{
var result = new Iec61850ControlCommandResult { IsSuccess = true };
var notice = Iec61850ControlShout.FromResult(result);
Assert.Equal("Command succeeded", notice.Title);
}

[Fact]
public void UnexpectedFailure_PreservesExactErrorInDiagnosticsNotOverlay()
{
var notice = Iec61850ControlShout.FromUnexpectedFailure();
Assert.Contains("Diagnostics", notice.Detail, StringComparison.Ordinal);
}

[Fact]
public void IedErrorString_CannotInjectMultilineOrUnboundedText()
{
var result = new Iec61850ControlCommandResult
{
AddCause = "blocked-by-interlocking",
Message = "reason",
CompletionState = "Rejected"
};
var notice = Iec61850ControlShout.FromResult(result);
Assert.DoesNotContain("\n", notice.Title, StringComparison.Ordinal);
Assert.DoesNotContain("\r", notice.Detail, StringComparison.Ordinal);
Assert.True(notice.Title.Length <= 100);
Assert.True(notice.Detail.Length <= 251);
}

[Fact]
public void UiOverlay_IsNonmodalAndReusesDispatcherTimer()
{
var xaml = Read("MainWindow.xaml");
var code = Read("MainWindow.xaml.cs");
Assert.Contains("x:Name=\"ControlShoutCard\"", xaml, StringComparison.Ordinal);
Assert.Contains("Panel.ZIndex=\"90\"", xaml, StringComparison.Ordinal);
Assert.Contains("Interval = TimeSpan.FromSeconds(8)", code, StringComparison.Ordinal);
Assert.Contains("ControlShoutCard.Visibility = Visibility.Collapsed", code, StringComparison.Ordinal);
Assert.Contains("Dispatcher.CheckAccess()", code, StringComparison.Ordinal);
Assert.DoesNotContain("MessageBoxImage.Warning", code, StringComparison.Ordinal);
}

[Fact]
public void IdentityProjection_AndSingleFlightProtection_StillActive()
{
var model = Read("Models/MonitorModels.cs");
var runtime = Read("Services/Iec61850MonitorRuntime.cs");
Assert.Contains("Iec61850ControlIdentity.DistinctOperable(Signals)", model, StringComparison.Ordinal);
Assert.Contains("_activeControlTargets.TryAdd(targetKey, 0)", runtime, StringComparison.Ordinal);
Assert.Contains("_activeControlTargets.TryRemove(targetKey, out _)", runtime, StringComparison.Ordinal);
}

private static string Read(string path)
{
DirectoryInfo? dir = new(AppContext.BaseDirectory);
while (dir is not null)
{
var file = Path.Combine(dir.FullName, path);
if (File.Exists(file)) return File.ReadAllText(file);
dir = dir.Parent;
}
throw new FileNotFoundException(path);
}
}