diff --git a/MainWindow.CommandPanelUx.cs b/MainWindow.CommandPanelUx.cs index 0b78eaf2..d1c80a85 100644 --- a/MainWindow.CommandPanelUx.cs +++ b/MainWindow.CommandPanelUx.cs @@ -7,7 +7,6 @@ using System.Windows.Media; using System.Windows.Threading; using ArIED61850Tester.Models; -using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -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) @@ -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(); @@ -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()); @@ -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) @@ -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); diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index b1678f85..ebff4ddf 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -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; @@ -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(); @@ -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); @@ -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) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index e36e627f..5f23013b 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Globalization; +using System.Diagnostics; using ArIED61850Tester.Models; namespace ArIED61850Tester.Services; @@ -69,6 +70,7 @@ private sealed class DeviceSession public DateTime NextHealthProbeUtc { get; set; } = DateTime.MinValue; public int ConsecutiveHealthProbeFailures { get; set; } public string HealthProbePointKey { get; set; } = string.Empty; + public int ControlCommandActive; } private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); @@ -454,10 +456,28 @@ public async Task ExecuteControlAsync( if (!_sessions.TryGetValue(deviceId, out var session) || !session.Client.IsConnected) throw new InvalidOperationException("The IED must be connected before a command can be sent."); - Log("WARN", session.Device.Name, - $"Control requested: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}."); + Log("INFO", session.Device.Name, + $"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}."); + + var clientStopwatch = Stopwatch.StartNew(); + Interlocked.Increment(ref session.ControlCommandActive); + Iec61850ControlCommandResult result; + try + { + result = await session.Client.ExecuteControlAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + Interlocked.Decrement(ref session.ControlCommandActive); + } + clientStopwatch.Stop(); + + if (result.ServiceAccepted || result.FeedbackConfirmed || result.IsSuccess) + RecordSuccessfulIo(session); + + if (!request.TestMode && result.FeedbackConfirmed && !string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") + ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue); - var result = await session.Client.ExecuteControlAsync(request, cancellationToken).ConfigureAwait(false); var protocolEvidence = string.Join("; ", new[] { string.IsNullOrWhiteSpace(result.CompletionState) ? null : $"completion={result.CompletionState}", @@ -467,7 +487,8 @@ public async Task ExecuteControlAsync( result.ControlNumber == "-" ? null : $"ctlNum={result.ControlNumber}", result.ElapsedText == "-" ? null : $"control={result.ElapsedText}", result.FeedbackElapsedText == "-" ? null : $"feedback={result.FeedbackElapsedText}", - result.TotalElapsedText == "-" ? null : $"total={result.TotalElapsedText}" + result.TotalElapsedText == "-" ? null : $"engineTotal={result.TotalElapsedText}", + $"clientTotal={clientStopwatch.Elapsed.TotalMilliseconds:0.###} ms" }.Where(text => !string.IsNullOrWhiteSpace(text))); Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name, @@ -475,6 +496,38 @@ public async Task ExecuteControlAsync( return result; } + private void ApplyControlFeedbackToMonitor(DeviceSession session, SignalDefinition signal, string feedbackValue) + { + var references = new[] + { + signal.ControlStatusReference, + string.IsNullOrWhiteSpace(signal.ObjectReference) ? string.Empty : signal.ObjectReference.TrimEnd('.') + ".stVal" + }; + + Iec61850MonitorPoint? point = null; + foreach (var reference in references.Where(reference => !string.IsNullOrWhiteSpace(reference))) + { + point = FindPointForReportReference(session, reference); + if (point != null) + break; + } + + if (point == null || !session.States.TryGetValue(point.PointKey, out var state)) + return; + + ApplyValueUpdate( + session, + point, + feedbackValue, + state.Quality, + state.DeviceTimestamp, + "Control feedback", + "confirmed command feedback", + DateTime.UtcNow, + "Live / control feedback confirmed", + trustReportEdge: false); + } + public async Task StopMonitoringAsync(string deviceId) { if (!_sessions.TryGetValue(deviceId, out var session)) @@ -616,6 +669,12 @@ private async Task MonitorLoopAsync(DeviceSession session, CancellationToken can { try { + if (Volatile.Read(ref session.ControlCommandActive) > 0) + { + await Task.Delay(5, cancellationToken).ConfigureAwait(false); + continue; + } + if (!session.Client.IsConnected) { MarkSessionOffline(session, "IEC 61850 transport is offline; smart reconnect is pending."); @@ -730,6 +789,8 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT var batchCount = Math.Min(plans.Count, 4); for (var offset = 0; offset < batchCount; offset++) { + if (Volatile.Read(ref session.ControlCommandActive) > 0) + break; cancellationToken.ThrowIfCancellationRequested(); var index = (session.ReportPlanCursor + offset) % plans.Count; var plan = plans[index]; @@ -942,8 +1003,10 @@ private static string ReportName(string actualReference, string plannedReference private async Task PollDuePointsAsync(DeviceSession session, CancellationToken cancellationToken) { var processed = 0; - while (processed < 8 && session.PollQueue.TryPeek(out var pointKey, out var dueTicks)) + while (processed < 4 && session.PollQueue.TryPeek(out var pointKey, out var dueTicks)) { + if (Volatile.Read(ref session.ControlCommandActive) > 0) + break; var nowUtc = DateTime.UtcNow; if (dueTicks > nowUtc.Ticks) break; @@ -1261,6 +1324,8 @@ private void MarkSessionOffline(DeviceSession session, string detail) private async Task ProbeSessionHealthAsync(DeviceSession session, CancellationToken cancellationToken) { + if (Volatile.Read(ref session.ControlCommandActive) > 0) + return; var now = DateTime.UtcNow; if (now < session.NextHealthProbeUtc || now - session.LastSuccessfulIoUtc < TimeSpan.FromMilliseconds(900)) return; session.NextHealthProbeUtc = now.AddSeconds(1); diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index dd1f0818..2892f270 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -27,6 +27,7 @@ public sealed class NativeIec61850Client : IIec61850Client, IIec61850ControlClie private readonly Dictionary> _reportMonitorCoverage = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _controlSessions = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _controlSessionGate = new(1, 1); + private readonly SemaphoreSlim _controlCommandGate = new(1, 1); private string _host = string.Empty; private int _port = 102; private int _engineCompatibilityWarningIssued; @@ -715,8 +716,18 @@ private static ArMms.MmsReportInventory BuildEngineReportInventory(ArMms.MmsRepo }); } + var forceDynamicPlan = ShouldForceDynamicReportPlan(plan); foreach (var rcb in source.ReportControls) - inventory.ReportControls.Add(CloneReportControl(rcb)); + { + var clone = CloneReportControl(rcb); + // A temporary dynamic DataSet must be paired with explicit dchg/qchg/dupd + // trigger options. Previously the dynamic planner inherited whatever TrgOps + // happened to be stored in the free URCB, so GI/integrity worked while CB + // position changes were never reported. + if (forceDynamicPlan) + ApplyDynamicPlanRequirements(clone, plan); + inventory.ReportControls.Add(clone); + } if (!string.IsNullOrWhiteSpace(plan.DataSetReference) && !inventory.DataSets.Any(ds => ReferencesEqual(ds.Reference, plan.DataSetReference))) @@ -801,12 +812,27 @@ private static void ApplyPlanHints(ArMms.MmsReportControlCandidate target, Repor target.ReportId = plan.ReportId; if (string.IsNullOrWhiteSpace(target.IntegrityPeriodMs) && plan.IntegrityPeriodMs > 0) target.IntegrityPeriodMs = plan.IntegrityPeriodMs.ToString(CultureInfo.InvariantCulture); + + if (plan.Status.Contains("Dynamic", StringComparison.OrdinalIgnoreCase)) + { + ApplyDynamicPlanRequirements(target, plan); + return; + } + if (string.IsNullOrWhiteSpace(target.TriggerOptions) && !string.IsNullOrWhiteSpace(plan.TriggerOptions)) target.TriggerOptions = plan.TriggerOptions; if (string.IsNullOrWhiteSpace(target.OptionalFields) && !string.IsNullOrWhiteSpace(plan.OptionalFields)) target.OptionalFields = plan.OptionalFields; } + private static void ApplyDynamicPlanRequirements(ArMms.MmsReportControlCandidate target, ReportControlPlan plan) + { + if (!string.IsNullOrWhiteSpace(plan.TriggerOptions)) + target.TriggerOptions = plan.TriggerOptions; + if (!string.IsNullOrWhiteSpace(plan.OptionalFields)) + target.OptionalFields = plan.OptionalFields; + } + private static (string Domain, string LogicalNode, string Name) ParseDataSetReference(string reference) { var text = reference.Trim().Replace('$', '.'); @@ -1102,6 +1128,21 @@ public async Task InspectControlAsync( public async Task ExecuteControlAsync( Iec61850ControlCommandRequest request, CancellationToken cancellationToken) + { + await _controlCommandGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await ExecuteControlCoreAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + _controlCommandGate.Release(); + } + } + + private async Task ExecuteControlCoreAsync( + Iec61850ControlCommandRequest request, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); if (!_session.IsMmsInitiated) @@ -1145,6 +1186,25 @@ public async Task ExecuteControlAsync( effectiveCdc, cancellationToken).ConfigureAwait(false); + capabilities = BuildControlCapabilities(descriptor, request.Signal, initialFeedback.Value, effectiveCdc); + request.Signal.ControlCdc = capabilities.ControlCdc; + request.Signal.ControlValueType = capabilities.ControlValueType; + request.Signal.ControlStatusReference = capabilities.StatusReference; + request.Signal.ControlModelReference = capabilities.ControlModelReference; + request.Signal.ControlModelText = capabilities.ControlModelText; + request.Signal.ControlCurrentValue = initialFeedback.Value; + + if (!request.TestMode && initialFeedback.IsSuccess && + ControlFeedbackMatches(effectiveCdc, expectedValue, initialFeedback.Value, initialFeedback.Value)) + { + totalStopwatch.Stop(); + return ControlAlreadyAtRequestedState( + capabilities, + expectedValue, + initialFeedback.Value, + totalStopwatch.Elapsed); + } + var nativeRequest = new ArControl.Iec61850ControlRequest { ControlValue = controlValue!, @@ -1769,6 +1829,30 @@ private static string BuildControlStatusReference(string reference, string cdc) : string.Empty; } + private static Iec61850ControlCommandResult ControlAlreadyAtRequestedState( + Iec61850ControlCapabilities capabilities, + string requestedValue, + string currentValue, + TimeSpan elapsed) + => new() + { + IsSuccess = true, + ServiceAccepted = false, + FeedbackConfirmed = true, + CommandTerminationReceived = false, + PositiveTermination = false, + CompletionState = "NotSent", + Stage = "Already at requested state", + Message = $"Live MMS preflight confirmed {currentValue}; no SBOw/Operate command was sent.", + ControlModelText = capabilities.ControlModelText, + SequenceText = "Live preflight only • no command sent", + RequestedValue = requestedValue, + FeedbackValue = currentValue, + ElapsedText = "0 ms", + FeedbackElapsedText = "0 ms", + TotalElapsedText = $"{elapsed.TotalMilliseconds:0.###} ms" + }; + private static Iec61850ControlCommandResult ControlFailure( string stage, string message, @@ -1803,6 +1887,7 @@ public async ValueTask DisposeAsync() } _mmsIoGate.Dispose(); _controlSessionGate.Dispose(); + _controlCommandGate.Dispose(); } private async Task GetOrOpenControlSessionAsync( diff --git a/docs/control-first-click-diagnostic-analysis.md b/docs/control-first-click-diagnostic-analysis.md new file mode 100644 index 00000000..70da9a51 --- /dev/null +++ b/docs/control-first-click-diagnostic-analysis.md @@ -0,0 +1,14 @@ +# Control first-click and dynamic report analysis + +The field diagnostic records successful native control sequences around 420–426 ms, but also records CB position changes discovered by MMS validation rather than the armed dynamic report. This follow-up separates UI click, runtime queue, native control, and feedback timing; prioritizes control traffic; and enforces dchg/qchg/dupd trigger options on the selected dynamic RCB. + +Safety boundary: no automatic Operate retry and no hidden duplicate SBOw/Operate sequence. + +## Field verification + +1. Clear diagnostics, then issue alternating Open and Close commands. +2. Every physical click must immediately create `Control click accepted` followed by `Control intent accepted`. +3. A state-changing click must produce exactly one SBOw → Operate sequence. +4. A redundant click may return `Already at requested state`, but must not issue Operate. +5. Compare click-to-result, client-total, engine-total, control, and feedback timings to locate any remaining delay. +6. Confirm CB position changes arrive from the dynamic RCB without the `MMS validation detected a value change not delivered by the armed report` warning.