diff --git a/MainWindow.xaml b/MainWindow.xaml index 05096b8a..95dbd49c 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -111,12 +111,12 @@ - - + + - + @@ -192,41 +192,26 @@ - + - + + + + + + + + + - - - - - - - - - - - - - - - - - + - + @@ -291,10 +276,10 @@ + TextWrapping="Wrap" Margin="0,3,0,0" MinHeight="28" MaxWidth="184"/> - + Style="{StaticResource DiscoveryProgressBar}" Width="178" Height="8" Margin="0,8,0,0"/> + @@ -304,24 +289,10 @@ - - - - - - - - - - - - - - - - - - + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 05305aed..9e0b2ca5 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -766,77 +766,85 @@ private async void ControlQuickAction_Click(object sender, RoutedEventArgs e) private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requestedValue) { - var device = SelectedDevice; - if (device == null || signal.ControlIsBusy) - return; + var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice; + if (device == null) + return; - if (!CommandTestMode && !LiveControlArmed) + if (signal.ControlIsBusy) { - 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; + SetStatus($"{device.Name}: {signal.Name} command is already in progress."); + return; } - if (!device.IsConnected) + if (!CommandTestMode && !LiveControlArmed) { - 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; + 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.ControlIsBusy = true; - signal.ControlLastResult = $"Sending {requestedValue}…"; + signal.ControlLastResult = $"Dispatching {requestedValue}…"; + SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…"); + await Dispatcher.Yield(DispatcherPriority.Render); + try { - if (signal.ControlModelText == "Auto-detect" || signal.ControlCurrentValue == "-") - { - var capabilities = await _runtime.InspectControlAsync( - device.DeviceId, - signal, - _applicationCancellation.Token); - signal.ControlCurrentValue = capabilities.CurrentValue; - device.RefreshCommandSignalProjection(); - RebuildControlFeedbackIndex(device); - } - - 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; + } + + if (signal.ControlModelText == "Auto-detect" || signal.ControlCurrentValue == "-") + { + var capabilities = await _runtime.InspectControlAsync(device.DeviceId, signal, _applicationCancellation.Token); + signal.ControlCurrentValue = capabilities.CurrentValue; + device.RefreshCommandSignalProjection(); + RebuildControlFeedbackIndex(device); + } + + 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}"); } catch (OperationCanceledException) { - signal.ControlLastResult = "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}"); - 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; } } diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index e8a02c72..dd1f0818 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1028,7 +1028,9 @@ public async Task InspectControlAsync( var control = await GetOrOpenControlSessionAsync(signal, cancellationToken).ConfigureAwait(false); var descriptor = control.Descriptor; var effectiveCdc = ResolveControlSemanticCdc(descriptor, signal); - var status = await control.ReadStatusAsync(cancellationToken).ConfigureAwait(false); + var status = await RunMmsOperationAsync( + () => control.ReadStatusAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); var normalizedStatus = NormalizeControlFeedback(effectiveCdc, status.DisplayValue, status.State); var currentValue = status.IsSuccess ? normalizedStatus.Value : "-"; var currentState = normalizedStatus.State.ToString(); @@ -1160,7 +1162,9 @@ public async Task ExecuteControlAsync( ArControl.Iec61850ControlActionResult action; try { - action = await control.OperateAsync(nativeRequest, cancellationToken).ConfigureAwait(false); + action = await RunMmsOperationAsync( + () => control.OperateAsync(nativeRequest, cancellationToken), + cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -1313,7 +1317,9 @@ private Iec61850ControlCapabilities BuildControlCapabilities( } } - var status = await control.ReadStatusAsync(cancellationToken).ConfigureAwait(false); + var status = await RunMmsOperationAsync( + () => control.ReadStatusAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); var normalizedStatus = NormalizeControlFeedback(feedbackCdc, status.DisplayValue, status.State); return (status.IsSuccess, normalizedStatus.Value, normalizedStatus.State); } @@ -1811,7 +1817,9 @@ public async ValueTask DisposeAsync() return existing; var service = new ArControl.Iec61850ControlService(); - var opened = await service.OpenAsync(_session, signal.ObjectReference, cancellationToken).ConfigureAwait(false); + var opened = await RunMmsOperationAsync( + () => service.OpenAsync(_session, signal.ObjectReference, cancellationToken), + cancellationToken).ConfigureAwait(false); _controlSessions[key] = opened; return opened; } diff --git a/docs/command-dispatch-field-test.md b/docs/command-dispatch-field-test.md new file mode 100644 index 00000000..f018fef4 --- /dev/null +++ b/docs/command-dispatch-field-test.md @@ -0,0 +1,7 @@ +# Command dispatch field-test notes + +This follow-up serializes IEC 61850 control-session open, status reads, and Operate traffic with the per-IED MMS I/O gate. The command row is latched busy on the first click and resolves its owning IED directly. + +The change deliberately does not retry commands automatically and does not issue a second Select, SBOw, or Operate sequence. + +Field validation should confirm one-click Open/Close operation, positive/negative CommandTermination handling, and process feedback timing while reporting and polling are active.