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
1 change: 1 addition & 0 deletions App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ protected override void OnStartup(StartupEventArgs e)
// isolated dynamic-DataSet qualification; it is never invoked by normal startup,
// Connect/Play, monitoring, reconnect or report-planner paths.
DynamicReportQualificationUiBehavior.Install();
DynamicReportCommandBoundWitnessUiBehavior.Install();
DispatcherUnhandledException += OnDispatcherUnhandledException;
TaskScheduler.UnobservedTaskException += (_, args) => args.SetObserved();

Expand Down
13 changes: 13 additions & 0 deletions ControlCommandWindow.A21Witness.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using ArIED61850Tester.Models;

namespace ArIED61850Tester;

/// <summary>
/// Read-only A2.1 adapter. This partial exposes context only; it adds no command
/// behavior and leaves the existing IEC 61850 control transaction completely untouched.
/// </summary>
public partial class ControlCommandWindow
{
internal SignalDefinition A21WitnessSignal => _signal;
internal Iec61850MonitorDevice A21WitnessDevice => _device;
}
130 changes: 130 additions & 0 deletions DynamicReportCommandBoundWitnessUiBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using ArIED61850Tester.Services;

namespace ArIED61850Tester;

internal static class DynamicReportCommandBoundWitnessUiBehavior
{
private static int _installed;
private static int _busy;

public static void Install()
{
if (Interlocked.Exchange(ref _installed, 1) != 0)
return;

EventManager.RegisterClassHandler(
typeof(MainWindow),
Keyboard.PreviewKeyDownEvent,
new KeyEventHandler(OnPreviewKeyDown),
handledEventsToo: true);

// Observer-only bridge for the dedicated ControlCommandWindow path. WPF class
// handlers execute before the window's existing SendCommand_Click instance
// handler. We only publish immutable intent context; the control handler and
// its ExecuteControlAsync/SBOw/Operate sequence remain untouched.
EventManager.RegisterClassHandler(
typeof(Button),
Button.ClickEvent,
new RoutedEventHandler(OnAnyButtonClick),
handledEventsToo: true);
}

private static void OnAnyButtonClick(object sender, RoutedEventArgs e)
{
if (sender is not Button button || Window.GetWindow(button) is not ControlCommandWindow commandWindow)
return;

var label = button.Content?.ToString()?.Trim() ?? string.Empty;
if (!label.Equals("Send Command", StringComparison.OrdinalIgnoreCase) &&
!label.Equals("Send Test", StringComparison.OrdinalIgnoreCase))
return;

if (!commandWindow.CanSend)
return;

DynamicReportCommandIntentObservation.Publish(new DynamicReportObservedCommandIntent(
commandWindow.A21WitnessDevice,
commandWindow.A21WitnessSignal,
commandWindow.SelectedValue,
"ControlCommandWindow.RoutedButtonClick",
DateTimeOffset.UtcNow));
}

private static async void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (sender is not MainWindow window ||
Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) ||
e.Key != Key.F)
return;

e.Handled = true;
var device = window.SelectedDevice;
if (device is null)
{
MessageBox.Show(
window,
"Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.",
"G2.5-A2.1 Command-Bound Witness",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}

if (Interlocked.Exchange(ref _busy, 1) != 0)
{
MessageBox.Show(
window,
"G2.5-A2.1 is already armed/running.",
"G2.5-A2.1 Command-Bound Witness",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}

try
{
var answer = MessageBox.Show(
window,
$"Arm G2.5-A2.1 command-bound high-speed stimulus witness for {device.Name} ({device.EndpointText})?\n\n" +
"READ-ONLY WITNESS + ONE EXISTING ARSAS CONTROL COMMAND\n\n" +
"A2.1 V2 opens one isolated read-only MMS association and captures a PRE-COMMAND baseline. It can observe BOTH the fast Command Panel and the dedicated Control Command dialog without changing either control transaction.\n\n" +
"After the status shows 'G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN or CLOSE using the normal ARSAS Command Panel or dedicated Control Command dialog you normally use. Do NOT use an external/manual stimulus for this phase.\n\n" +
"The observer then narrows read-only sampling to at most six points around the exact ControlStatusReference. Existing ExecuteControlAsync, SBOw, Operate and CommandTermination behavior is NOT modified, delayed, wrapped or re-issued.\n\n" +
"Once 'G2.5-A2.1 COMMAND CAPTURED' appears, do not issue another command. If a transition is seen, A2.1 samples briefly to classify persistent/latched versus momentary/pulse behavior.\n\n" +
"The witness does not access/mutate RCB or DataSet state, does not send GI, does not save/advance the qualification profile, and production dynamic reporting remains OFF. Do not run another G2 hotkey while A2.1 is armed.\n\n" +
"Continue?",
"G2.5-A2.1 Command-Bound High-Speed Witness",
MessageBoxButton.YesNo,
MessageBoxImage.Warning,
MessageBoxResult.No);
if (answer != MessageBoxResult.Yes)
return;

window.LastStatusText = $"G2.5-A2.1 V2: opening isolated read-only MMS witness for {device.Name} and preparing pre-command baseline…";
var progress = new Progress<string>(text => window.LastStatusText = text);
var service = new DynamicReportCommandBoundStimulusWitnessServiceV2();
var result = await service.RunAsync(device, device.Signals.ToArray(), progress, CancellationToken.None);
window.LastStatusText = result.Summary;
var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window };
evidenceWindow.ShowDialog();
}
catch (Exception ex)
{
window.LastStatusText = "G2.5-A2.1 V2 stopped locally; production dynamic reporting remains OFF.";
MessageBox.Show(
window,
"G2.5-A2.1 V2 stopped. The witness did not change production reporting policy.\n\n" + ex,
"G2.5-A2.1 Command-Bound Witness",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
finally
{
Interlocked.Exchange(ref _busy, 0);
}
}
}
114 changes: 114 additions & 0 deletions DynamicReportQualificationResultWindow.G25A21.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Globalization;
using System.Text;
using ArIED61850Tester.Services;

namespace ArIED61850Tester;

internal partial class DynamicReportQualificationResultWindow
{
internal DynamicReportQualificationResultWindow(DynamicReportCommandBoundStimulusWitnessResult result)
{
ArgumentNullException.ThrowIfNull(result);
InitializeComponent();

Title = "G2.5-A2.1 Command-Bound Stimulus Witness Evidence";
HeaderText.Text = "G2.5-A2.1 Command-Bound High-Speed Witness";
SummaryText.Text = result.Summary;
StateText.Text = result.IsSuccess
? "Command-Bound Transition Proven"
: result.IsBlocked
? "Blocked"
: "Command-Bound Transition Not Proven";
EvidenceTextBox.Text = BuildG25A21Evidence(result);

if (result.IsSuccess)
SetPassBadge();
}

private static string BuildG25A21Evidence(DynamicReportCommandBoundStimulusWitnessResult result)
{
var builder = new StringBuilder();
builder.AppendLine("ARSAS G2.5-A2.1 COMMAND-BOUND HIGH-SPEED STIMULUS WITNESS EVIDENCE");
builder.AppendLine(new string('=', 92));
builder.AppendLine($"Result: {result.Summary}");
builder.AppendLine($"Blocked: {result.IsBlocked}");
builder.AppendLine($"G2.5-A2.1 success: {result.IsSuccess}");
builder.AppendLine($"Stimulus witness proven: {result.StimulusWitnessProven}");

if (result.Identity is not null)
{
builder.AppendLine();
builder.AppendLine("IED IDENTITY");
builder.AppendLine($"Stable identity: {result.Identity.StableIdentityKey}");
builder.AppendLine($"Model fingerprint: {result.Identity.ModelFingerprint}");
builder.AppendLine($"Model: {result.Identity.Model}");
builder.AppendLine($"Firmware: {result.Identity.FirmwareRevision}");
builder.AppendLine($"Profile revision: {result.Identity.ProfileRevision}");
}

builder.AppendLine();
builder.AppendLine("COMMAND BINDING");
builder.AppendLine($"Input profile state: {result.InputProfile?.State.ToString() ?? "-"}");
builder.AppendLine($"Pre-command baseline captured: {result.BaselineCaptured}");
builder.AppendLine($"Command captured: {result.CommandCaptured}");
builder.AppendLine($"Command signal: {G25A21TextOrDash(result.CommandSignalReference)}");
builder.AppendLine($"ControlStatusReference: {G25A21TextOrDash(result.ControlStatusReference)}");
builder.AppendLine($"Control model: {G25A21TextOrDash(result.ControlModelText)}");
builder.AppendLine($"Pre-command baseline points: {result.PreCommandBaselineCount}");
builder.AppendLine($"Focused candidates: {result.FocusCandidateCount}");
builder.AppendLine($"Sample cycles: {result.SampleCycles}");
builder.AppendLine($"Read failures: {result.ReadFailures}");
builder.AppendLine($"Association healthy: {result.AssociationHealthy}");

builder.AppendLine();
builder.AppendLine("ELIGIBLE COMMAND-BOUND CANDIDATES");
if (result.EligibleCandidates.Count == 0)
{
builder.AppendLine(" none");
}
else
{
foreach (var candidate in result.EligibleCandidates)
{
builder.AppendLine($" #{candidate.Rank} {candidate.Reference}");
builder.AppendLine($" MMS: {candidate.MmsReference}");
builder.AppendLine($" Exact ControlStatusReference: {candidate.ExactControlStatus}");
builder.AppendLine($" Kind: {candidate.Kind}");
builder.AppendLine($" Baseline -> final: {candidate.BaselineValue} -> {candidate.FinalValue}");
builder.AppendLine($" Transitions: {candidate.TransitionCount}");
builder.AppendLine($" Observed active/pulse duration ms: {G25A21FormatDuration(candidate.ObservedActiveMilliseconds)}");
foreach (var transition in candidate.Transitions)
builder.AppendLine($" {transition.ObservedAtUtc:O} | {transition.BeforeValue} -> {transition.AfterValue}");
}
}

builder.AppendLine();
builder.AppendLine("ALL FOCUSED OBSERVATIONS");
foreach (var candidate in result.Observations)
{
builder.AppendLine($" exact={candidate.ExactControlStatus,-5} | transitions={candidate.TransitionCount,2} | {candidate.Kind,-20} | {candidate.Reference} | {candidate.BaselineValue} -> {candidate.FinalValue}");
}

builder.AppendLine();
builder.AppendLine("WIRE / DIAGNOSTIC EVIDENCE");
foreach (var line in result.EvidenceLines)
builder.AppendLine(line);

builder.AppendLine();
builder.AppendLine("SAFETY STATE");
builder.AppendLine("G2.5-A2.1 witness is read-only and does not alter, delay, wrap or re-issue the existing ARSAS control transaction.");
builder.AppendLine("The one OPEN/CLOSE command is the operator-requested existing ARSAS control action; the witness itself performs no control write.");
builder.AppendLine("G2.5-A2.1 does not access/mutate RCB/DataSet state, send GI, save the InformationReportProven profile, or enable production dynamic reporting.");
builder.AppendLine("G2.5-A2.1 PASS identifies a command-bound physical MMS candidate only; it does NOT prove spontaneous dchg reporting.");
builder.AppendLine("Production automatic dynamic reporting remains OFF.");
return builder.ToString();
}

private static string G25A21FormatDuration(double? milliseconds)
=> milliseconds.HasValue
? milliseconds.Value.ToString("0.0", CultureInfo.InvariantCulture)
: "-";

private static string G25A21TextOrDash(string? value)
=> string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
}
Loading
Loading