Skip to content
29 changes: 12 additions & 17 deletions MainWindow.CommandPanelUx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using System.Windows.Media;
using System.Windows.Threading;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ArIED61850Tester;

Expand All @@ -32,18 +31,11 @@ public object Convert(object[] values, Type targetType, object parameter, Cultur
var testMode = values.ElementAtOrDefault(1) is true;
var busy = values.ElementAtOrDefault(2) is true;
var supportsOperate = values.ElementAtOrDefault(3) is true;
var current = values.ElementAtOrDefault(4)?.ToString() ?? string.Empty;
var command = parameter?.ToString() ?? string.Empty;
return (liveArmed || testMode) && supportsOperate && !busy && (testMode || !AlreadyActive(command, current));
}

private static bool AlreadyActive(string command, string current)
{
if (string.IsNullOrWhiteSpace(command) || string.IsNullOrWhiteSpace(current) || current.Trim() == "-") return false;
if (Iec61850ValueFormatter.TryNormalizeDbpos(command, out var requested) &&
Iec61850ValueFormatter.TryNormalizeDbpos(current, out var actual)) return requested == actual;
if (bool.TryParse(command, out var requestedBool) && bool.TryParse(current, out var actualBool)) return requestedBool == actualBool;
return command.Trim().Equals(current.Trim(), StringComparison.OrdinalIgnoreCase);
// Never disable Open/Close from the last displayed process value. Report and
// UI batching can be stale for a short time, which previously made the first
// click disappear. The live MMS preflight in the control engine is the only
// authority that may suppress a redundant command.
return (liveArmed || testMode) && supportsOperate && !busy;
}

public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
Expand Down Expand Up @@ -110,7 +102,7 @@ private void InstallCommandPanelUx()

_commandPanelUxTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromMilliseconds(1500)
Interval = TimeSpan.FromMilliseconds(2500)
};
_commandPanelUxTimer.Tick += CommandPanelUxTimer_Tick;
_commandPanelUxTimer.Start();
Expand Down Expand Up @@ -215,14 +207,12 @@ private void ConfigureCommandPanelButton(Button button)
var enabledBinding = new MultiBinding
{
Converter = CommandButtonEnabledConverter.Instance,
ConverterParameter = content,
Mode = BindingMode.OneWay
};
enabledBinding.Bindings.Add(new Binding(nameof(LiveControlArmed)) { Source = this });
enabledBinding.Bindings.Add(new Binding(nameof(CommandTestMode)) { Source = this });
enabledBinding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlIsBusy)));
enabledBinding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlSupportsOperate)));
enabledBinding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlCurrentValue)));
BindingOperations.SetBinding(button, UIElement.IsEnabledProperty, enabledBinding);

_configuredCommandButtons.Add(button, new Marker());
Expand Down Expand Up @@ -267,6 +257,11 @@ private async Task PreloadControlModelsAsync()
{
foreach (var device in Devices.Where(device => device.IsConnected && device.SelectedControlSignalCount > 0))
{
// One MMS association is serialized. Do not queue background ctlModel
// inspection while an operator command owns the session.
if (device.CommandSignals.Any(signal => signal.ControlIsBusy))
continue;

var candidates = device.Signals
.Where(signal => signal.IsSelected && signal.IsValidControlObject)
.Where(signal => !signal.ControlModelResolved)
Expand All @@ -279,7 +274,7 @@ private async Task PreloadControlModelsAsync()
continue;
}

using var throttle = new SemaphoreSlim(3, 3);
using var throttle = new SemaphoreSlim(1, 1);
await Task.WhenAll(candidates.Select(async signal =>
{
await throttle.WaitAsync(_applicationCancellation.Token);
Expand Down
131 changes: 65 additions & 66 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
Expand Down Expand Up @@ -768,99 +769,89 @@ private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requ
{
var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice;
if (device == null)
return;
return;

if (signal.ControlIsBusy)
{
SetStatus($"{device.Name}: {signal.Name} command is already in progress.");
return;
SetStatus($"{device.Name}: {signal.Name} command is already in progress.");
return;
}

if (!CommandTestMode && !LiveControlArmed)
{
signal.ControlLastResult = "Enable Live control armed before sending a command.";
SetStatus("Live control is not armed. Review the selected IED and enable the Command Panel safety switch.");
return;
signal.ControlLastResult = "Enable Live control armed before sending a command.";
SetStatus("Live control is not armed. Review the selected IED and enable the Command Panel safety switch.");
return;
}

var clickStopwatch = Stopwatch.StartNew();
signal.ControlIsBusy = true;
signal.ControlLastResult = $"Dispatching {requestedValue}…";
SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…");
AddLog("INFO", device.Name,
$"Control click accepted: {signal.ObjectReference} value={requestedValue}; test={CommandTestMode}; interlock={CommandInterlockCheck}; synchro={CommandSynchroCheck}.");
await Dispatcher.Yield(DispatcherPriority.Render);

try
{
if (!device.IsConnected)
{
SetStatus($"{device.Name}: connecting before control…");
var connected = device.HasDiscoveryCache && device.Signals.Count > 0
? await ConnectUsingSavedModelAsync(device)
: await ConnectAndConfigureDeviceAsync(device, openWizard: false);
if (!connected)
return;
}

var capabilities = await _runtime.InspectControlAsync(device.DeviceId, signal, _applicationCancellation.Token);
signal.ControlCurrentValue = capabilities.CurrentValue;
device.RefreshCommandSignalProjection();
RebuildControlFeedbackIndex(device);
if (!capabilities.SupportsOperate)
throw new InvalidOperationException($"{signal.ObjectReference} is not command-ready: {capabilities.ControlModelText}.");
if (!CommandTestMode && SameControlState(signal, requestedValue, capabilities.CurrentValue))
{
var current = string.IsNullOrWhiteSpace(capabilities.CurrentValue) ? "the requested state" : capabilities.CurrentValue;
signal.ControlLastResult = $"Already {current} — no command was sent.";
SetStatus($"{device.Name}: {signal.Name} is already {current}; duplicate control suppressed.");
return;
}

var result = await _runtime.ExecuteControlAsync(
device.DeviceId,
new Iec61850ControlCommandRequest
{
Signal = signal,
ValueText = requestedValue,
InterlockCheck = CommandInterlockCheck,
SynchroCheck = CommandSynchroCheck,
TestMode = CommandTestMode,
FeedbackTimeoutMs = signal.IsPositionControl ? 12000 :
(signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000,
CommandTerminationTimeoutMs = 10000,
OriginCategory = "Maintenance"
},
_applicationCancellation.Token);

if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")
signal.ControlCurrentValue = result.FeedbackValue;

signal.ControlLastResult = BuildQuickControlResult(result);
SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}");
if (!device.IsConnected)
{
SetStatus($"{device.Name}: connecting before control…");
var connected = device.HasDiscoveryCache && device.Signals.Count > 0
? await ConnectUsingSavedModelAsync(device)
: await ConnectAndConfigureDeviceAsync(device, openWizard: false);
if (!connected)
return;
}

// ExecuteControlAsync owns one live status preflight and the complete control
// sequence. The old UI path performed a second status read before this call,
// which added queue latency and created a stale-value race.
var result = await _runtime.ExecuteControlAsync(
device.DeviceId,
new Iec61850ControlCommandRequest
{
Signal = signal,
ValueText = requestedValue,
InterlockCheck = CommandInterlockCheck,
SynchroCheck = CommandSynchroCheck,
TestMode = CommandTestMode,
FeedbackTimeoutMs = signal.IsPositionControl ? 12000 :
(signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000,
CommandTerminationTimeoutMs = 10000,
OriginCategory = "Maintenance"
},
_applicationCancellation.Token);

if (!string.IsNullOrWhiteSpace(result.ControlModelText))
signal.ControlModelText = result.ControlModelText;
if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")
signal.ControlCurrentValue = result.FeedbackValue;

signal.ControlLastResult = BuildQuickControlResult(result);
SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}");
clickStopwatch.Stop();
AddLog(result.IsSuccess ? "INFO" : "WARN", device.Name,
$"Control UI timing: {signal.ObjectReference}; click-to-result={clickStopwatch.Elapsed.TotalMilliseconds:0.###} ms; engine-total={result.TotalElapsedText}; serviceAccepted={result.ServiceAccepted}; stage={result.Stage}.");
}
catch (OperationCanceledException)
{
signal.ControlLastResult = "Command cancelled.";
SetStatus($"{device.Name}: {signal.Name} command cancelled.");
signal.ControlLastResult = "Command cancelled.";
SetStatus($"{device.Name}: {signal.Name} command cancelled.");
}
catch (Exception ex)
{
signal.ControlLastResult = $"Command failed: {ex.Message}";
AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}");
SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}");
MarkDiagnosticAlert();
signal.ControlLastResult = $"Command failed: {ex.Message}";
AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}");
SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}");
MarkDiagnosticAlert();
}
finally
{
signal.ControlIsBusy = false;
signal.ControlIsBusy = false;
}
}

private static bool SameControlState(SignalDefinition signal, string requested, string current)
{
if (signal.IsPositionControl && Iec61850ValueFormatter.TryNormalizeDbpos(requested, out var requestCode) && Iec61850ValueFormatter.TryNormalizeDbpos(current, out var currentCode)) return requestCode == currentCode;
if (signal.IsBooleanControl && bool.TryParse(requested, out var requestBool) && bool.TryParse(current, out var currentBool)) return requestBool == currentBool;
return requested.Trim().Equals(current.Trim(), StringComparison.OrdinalIgnoreCase);
}

private static string BuildQuickControlResult(Iec61850ControlCommandResult result)
{
var timing = new List<string>();
Expand Down Expand Up @@ -1095,6 +1086,8 @@ private void UiFlushTimer_Tick(object? sender, EventArgs e)
if (!_pendingPointSnapshots.TryRemove(pointKey, out var pending)) continue;
var snapshot = pending.Snapshot;
var point = snapshot.Point;
if (snapshot.Sequence < point.Sequence)
continue;
var uiDetectedEdge = point.ApplyProcessValue(snapshot.Value);
if (pending.HasValueEdge || snapshot.IsValueEdge || uiDetectedEdge)
MarkPointRecentlyChanged(point);
Expand Down Expand Up @@ -1758,7 +1751,13 @@ private static string NormalizeReference(string? reference)
private sealed record PendingPointUpdate(Iec61850PointSnapshot Snapshot, bool HasValueEdge)
{
public PendingPointUpdate Merge(Iec61850PointSnapshot next)
=> new(next, HasValueEdge || next.IsValueEdge);
{
// A command-confirmed snapshot can overtake an older report/poll snapshot in
// the 100 ms UI batching queue. Never let a lower process sequence roll the
// command row and live grid back to the previous breaker state.
var newest = next.Sequence < Snapshot.Sequence ? Snapshot : next;
return new PendingPointUpdate(newest, HasValueEdge || next.IsValueEdge);
}
}

private static string Csv(string value)
Expand Down
Loading
Loading