From da0581967b4020033467b80e6820e8cc50a7e042 Mon Sep 17 00:00:00 2001 From: LucianoDev Date: Wed, 22 Apr 2026 16:52:16 +0200 Subject: [PATCH] fix: gate Ford VP44 unlock on CAN liveness to prevent false-unlocked reads Before this fix, StartUnlockIfRequired was called immediately after registering the pump's CAN parameters, before any frames had been decoded. The TestUnlock parameter's zero-initialised Value was interpreted as "unlocked" for Type 1 pumps, causing Phase 1 to be skipped and UnlockCompleted(true) to fire falsely. Changes: - ICanService: add IsPumpAlive property (volatile-backed in PcanAdapter) - PcanAdapter: implement IsPumpAlive; mark _pumpAlive/_benchAlive volatile for safe cross-thread reads - MainViewModel: replace direct StartUnlockIfRequired call with a fire-and-forget WaitForPumpCanThenUnlockAsync that waits for PumpLivenessChanged(true) + 250 ms grace, then invokes unlock on the UI thread; cancellation on pump change or CAN disconnect via _pumpLivenessCts - UnlockService.UnlockAsync: skip Phase 2 state-machine when observer seed already reports unlocked (senders still run to prevent re-lock) Co-Authored-By: Claude Opus 4.7 (1M context) --- Infrastructure/Pcan/PcanAdapter.cs | 22 +- Services/ICanService.cs | 9 + Services/Impl/UnlockService.cs | 15 +- ViewModels/MainViewModel.cs | 384 ++++++++++++++++++++++++++--- 4 files changed, 388 insertions(+), 42 deletions(-) diff --git a/Infrastructure/Pcan/PcanAdapter.cs b/Infrastructure/Pcan/PcanAdapter.cs index a77b77a..56273b7 100644 --- a/Infrastructure/Pcan/PcanAdapter.cs +++ b/Infrastructure/Pcan/PcanAdapter.cs @@ -64,8 +64,10 @@ namespace HC_APTBS.Infrastructure.Pcan private HashSet _pumpMessageIds = new(); private DateTime _lastBenchFrameUtc = DateTime.MinValue; private DateTime _lastPumpFrameUtc = DateTime.MinValue; - private bool _benchAlive; - private bool _pumpAlive; + // volatile so IsPumpAlive/IsBenchAlive getters on other threads see transitions + // without relying on the memory-model guarantees of the event handler path. + private volatile bool _benchAlive; + private volatile bool _pumpAlive; // ── ICanService ────────────────────────────────────────────────────────── @@ -84,6 +86,9 @@ namespace HC_APTBS.Infrastructure.Pcan /// public bool IsConnected => !_stopRead; + /// + public bool IsPumpAlive => _pumpAlive; + /// public TPCANHandle SelectedChannel { @@ -246,11 +251,11 @@ namespace HC_APTBS.Infrastructure.Pcan { lock (_mapLock) { + // Replace-on-conflict: callers may re-register on pump switch; the + // new pump's parameter objects must take precedence over any stale + // objects from the previous pump that shared CAN IDs. foreach (var kv in parameters) - { - if (!_parameterMap.ContainsKey(kv.Key)) - _parameterMap.Add(kv.Key, kv.Value); - } + _parameterMap[kv.Key] = kv.Value; ResolveBenchRpmParam(); } } @@ -563,6 +568,11 @@ namespace HC_APTBS.Infrastructure.Pcan // result = prev + alpha * (new - prev) param.Value = PassFilterUpdate(previousValue, param.Value, param.Alpha); param.NeedsUpdate = true; + + // Notify observers (e.g. UnlockService) that the decoded value changed. + // The filter rounds to 4 decimals so this does not fire on float noise. + if (param.Value != previousValue) + param.RaiseValueChanged(); } } diff --git a/Services/ICanService.cs b/Services/ICanService.cs index d7a2fde..9063ed8 100644 --- a/Services/ICanService.cs +++ b/Services/ICanService.cs @@ -41,6 +41,15 @@ namespace HC_APTBS.Services /// True when the CAN read thread is running and the channel is open. bool IsConnected { get; } + /// + /// True when pump-ECU frames have been received within the last liveness window. + /// Mirrors the last value broadcast via . + /// Used by pump-selection flows (e.g. immobilizer unlock) to avoid reading stale + /// zero-initialised parameter values before the pump has actually started + /// transmitting on the bus. + /// + bool IsPumpAlive { get; } + /// /// The PCAN channel handle that will be used on the next call. /// Defaults to the channel supplied at construction. diff --git a/Services/Impl/UnlockService.cs b/Services/Impl/UnlockService.cs index 9589e82..61800d6 100644 --- a/Services/Impl/UnlockService.cs +++ b/Services/Impl/UnlockService.cs @@ -87,8 +87,19 @@ namespace HC_APTBS.Services.Impl ct.ThrowIfCancellationRequested(); // ── Phase 2: TestUnlock state machine ──────────────────────────────── - StatusChanged?.Invoke("Testing unlock..."); - await RunTestUnlockSequenceAsync(pump.UnlockType, ct).ConfigureAwait(false); + // Skip when the observer already latched an unlocked state — the + // four 0x700 commands are a no-op in that case and just keep the + // dispatcher/CAN bus busy for ~4 s. Senders remain running so the + // Ford ECU doesn't re-lock. + if (_isPumpUnlocked) + { + _log.Info(LogId, "Pump already unlocked — skipping Phase 2 state machine"); + } + else + { + StatusChanged?.Invoke("Testing unlock..."); + await RunTestUnlockSequenceAsync(pump.UnlockType, ct).ConfigureAwait(false); + } // ── Verify unlock status via CAN TestUnlock parameter ──────────────── bool success = VerifyUnlock(pump); diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 6a6545c..a82c56e 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -48,7 +48,7 @@ namespace HC_APTBS.ViewModels /// Pump selection and K-Line ECU identification are delegated to /// . /// - public sealed partial class MainViewModel : ObservableObject + public sealed partial class MainViewModel : ObservableObject, IAutoTestHost { // ── Services ────────────────────────────────────────────────────────────── @@ -60,12 +60,19 @@ namespace HC_APTBS.ViewModels private readonly IUnlockService _unlock; private readonly ILocalizationService _loc; private readonly IAppLogger _log; + private readonly IAutoTestOrchestrator _auto; private const string LogId = "MainViewModel"; // ── CancellationToken for test runs ─────────────────────────────────────── private CancellationTokenSource? _testCts; + // ── BIP poll counter ────────────────────────────────────────────────────── + + // Rate-limits KWP RAM reads to ~1 s intervals regardless of the refresh timer frequency. + private int _bipPollCounter; + private const int BipPollEveryNTicks = 10; + // ── Test elapsed timer ──────────────────────────────────────────────────── /// Ticks every second while a test is running to update . @@ -79,9 +86,27 @@ namespace HC_APTBS.ViewModels /// CTS for the currently running immobilizer unlock, if any. private CancellationTokenSource? _unlockCts; + /// The in-flight task, tracked so + /// a rapid pump switch can await prior cancellation before starting a new unlock. + private Task? _unlockTask; + /// ViewModel for the non-modal unlock progress window. private UnlockProgressViewModel? _unlockVm; + /// The pump active before the most recent call. + /// Used to unregister the prior pump's CAN parameters from the bus adapter so stale + /// parameter objects don't keep absorbing frames meant for the new pump. + private PumpDefinition? _previousPump; + + /// CTS for the "wait for pump CAN liveness, then start unlock" gate. + /// Cancelled whenever a new pump is selected so a rapid pump switch doesn't + /// leak a stale wait task that would race against the new pump's unlock flow. + private CancellationTokenSource? _pumpLivenessCts; + + /// True if the most recent unlock for the current pump succeeded. + /// Reset on pump change so the test-start gate survives snackbar auto-dismiss. + private bool _lastUnlockSucceeded; + /// /// Publicly observable accessor for the currently running (or last completed) /// immobilizer unlock VM. Used by the Pump page's inline unlock panel to @@ -101,12 +126,18 @@ namespace HC_APTBS.ViewModels } } + /// Raised after operator saves settings — consumed by child VMs that have settings-dependent runtime state. + public event Action? SettingsSaved; + /// Remembers the last authenticated username to pre-fill the next auth dialog. private string _lastAuthenticatedUser = string.Empty; /// Tracks whether the last selected pump required 27 V, for transition-based voltage warnings. private bool _lastPumpWas27V; + /// Configuration service — exposed for child VMs that need settings at construction time. + public IConfigurationService Config => _config; + // ── Child ViewModels ────────────────────────────────────────────────────── /// ViewModel for pump selection and K-Line ECU identification. @@ -139,6 +170,12 @@ namespace HC_APTBS.ViewModels /// ViewModel for the second pump status display (Empf3 word). public StatusDisplayViewModel StatusDisplay2 { get; } = new(); + /// + /// ViewModel for the BIP-STATUS display (PSG5-PI pumps only). + /// is false for non-PSG5-PI pumps. + /// + public BipDisplayViewModel BipDisplay { get; } = new(); + /// ViewModel for the Dashboard's active-alarm list. public DashboardAlarmsViewModel DashboardAlarms { get; } @@ -184,7 +221,8 @@ namespace HC_APTBS.ViewModels IPdfService pdfService, IUnlockService unlockService, ILocalizationService localizationService, - IAppLogger logger) + IAppLogger logger, + IAutoTestOrchestrator autoTestOrchestrator) { _can = canService; _kwp = kwpService; @@ -194,6 +232,7 @@ namespace HC_APTBS.ViewModels _unlock = unlockService; _loc = localizationService; _log = logger; + _auto = autoTestOrchestrator; _loc.LanguageChanged += RefreshLocalisedStrings; @@ -220,6 +259,21 @@ namespace HC_APTBS.ViewModels // React to pump changes from the identification child VM. PumpIdentification.PumpChanged += OnPumpChanged; + // Dashboard auto-test button is gated on DashboardAlarms.HasCritical. + DashboardAlarms.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(DashboardAlarmsViewModel.HasCritical)) + App.Current.Dispatcher.Invoke(() => + ConnectAndAutoTestCommand.NotifyCanExecuteChanged()); + }; + + // Orchestrator state transitions also gate the button. + _auto.StateChanged += (_, _) => App.Current.Dispatcher.Invoke(() => + { + ConnectAndAutoTestCommand.NotifyCanExecuteChanged(); + CancelAutoTestCommand.NotifyCanExecuteChanged(); + }); + // Sync sliders when test execution sets pump control values. _bench.PumpControlValueSet += (name, value) => App.Current.Dispatcher.Invoke( () => PumpControl.SetValueFromTest(name, value)); @@ -242,6 +296,13 @@ namespace HC_APTBS.ViewModels _kwp.KLineStateChanged += state => App.Current.Dispatcher.Invoke(() => KLineState = state); + // BIP status word → BipDisplay (PSG5-PI pumps only) + _kwp.BipStatusRead += word => + { + if (CurrentPump?.BipStatus is { } bipDef) + App.Current.Dispatcher.Invoke(() => BipDisplay.UpdateBipWord(bipDef, word)); + }; + // Bench service events _bench.TestStarted += OnTestStarted; _bench.TestFinished += OnTestFinished; @@ -269,7 +330,7 @@ namespace HC_APTBS.ViewModels _bench.ToleranceUpdated += (paramName, value, tolerance) => App.Current.Dispatcher.Invoke( () => { - TestPanel.UpdateLiveIndicator(paramName, value); + TestPanel.ApplyToleranceUpdate(paramName, value, tolerance); FlowmeterChart.SetTolerance(paramName, value, tolerance); if (paramName == BenchParameterNames.Pressure) BenchPage.PressureTrace.P1.SetTolerance(value, tolerance); @@ -309,6 +370,8 @@ namespace HC_APTBS.ViewModels // Unlock service status → verbose display _unlock.StatusChanged += msg => App.Current.Dispatcher.Invoke( () => VerboseStatus = msg); + _unlock.UnlockCompleted += success => App.Current.Dispatcher.Invoke( + () => _lastUnlockSucceeded = success); // KWP pump power-cycle callbacks kwpService.PumpDisconnectRequested += OnKwpDisconnectPump; @@ -320,14 +383,78 @@ namespace HC_APTBS.ViewModels /// Convenience accessor for the currently loaded pump definition. public PumpDefinition? CurrentPump => PumpIdentification.CurrentPump; + /// + public Task EnsureOilPumpOnAsync(bool skipConfirmation) + { + // Always marshal to the UI thread: ShowDialog and BenchControlViewModel + // are UI-affine. Invoked from AutoTestOrchestrator which may run on a + // background continuation. + return Application.Current.Dispatcher.InvokeAsync(() => + { + // Already on — short-circuit. Covers the case where the operator + // turned the pump on manually before pressing "Connect & Auto Test". + if (BenchControl.IsOilPumpOn) return true; + + if (skipConfirmation) + { + BenchControl.TurnOilPumpOnSilent(); + return true; + } + + // Present the same leak-check dialog the manual Bench page uses. + var vm = new OilPumpConfirmViewModel(); + var dlg = new OilPumpConfirmDialog(vm) { Owner = Application.Current.MainWindow }; + dlg.ShowDialog(); + if (!vm.Accepted) return false; + + BenchControl.TurnOilPumpOnSilent(); + return true; + }).Task; + } + private void OnPumpChanged(PumpDefinition? pump) { if (pump == null) return; + _log.Info(LogId, $"OnPumpChanged: {pump.Id}"); + + // Open the slider gate FIRST so the operator can command the pump as soon + // as the identifier is known — even if the K-Line read is still running + // and the heavier work below (LoadAllTests, senders, unlock) hasn't finished. + PumpControl.IsPreInAvailable = pump.HasPreInjection; + PumpControl.IsEnabled = true; + PumpControl.Reset(); + _log.Info(LogId, $"OnPumpChanged: slider gate opened for {pump.Id}"); + + // Cancel any in-flight "wait for CAN liveness then unlock" gate from + // the previous pump selection. Must happen before StopSenders so a + // pending wait can't race and start an unlock against the outgoing pump. + _pumpLivenessCts?.Cancel(); + _pumpLivenessCts?.Dispose(); + _pumpLivenessCts = null; + + // New pump lifecycle — stop any persistent unlock senders from the + // previous pump. Must happen before CloseUnlockDialog tears down the + // previous VM. The Ford ECU re-locks if 0x300/0x700 stops mid-session, + // so senders are only stopped here (on pump change), not on dismiss. + _unlock.StopSenders(); + // Tear down the previous pump's unlock-state observer; a new one is + // started below in StartUnlockIfRequired once the new pump's CAN + // parameters are registered. + _unlock.StopObserver(); + _lastUnlockSucceeded = false; + // Stop any senders from the previous pump. _bench.StopMemoryRequestSender(); _bench.StopPumpSender(); + // Unregister the previous pump's CAN parameters BEFORE adding the new + // pump's map. Most VP44 pumps share CAN IDs (Status, Empf3, etc.), so + // without this step the new pump's parameter objects would be masked + // by stale ones still decoding frames into the old pump's state. + if (_previousPump != null && !ReferenceEquals(_previousPump, pump)) + _can.RemoveParameters(_previousPump.ParametersById); + // Register the pump with BenchService so ReadParameter/SetParameter resolve pump params. _bench.SetActivePump(pump); @@ -338,11 +465,6 @@ namespace HC_APTBS.ViewModels _can.AddParameters(pump.ParametersById); _can.RegisterPumpMessageIds(GetReceiveMessageIds(pump.ParametersById)); - // Configure pump control sliders. - PumpControl.IsPreInAvailable = pump.HasPreInjection; - PumpControl.IsEnabled = true; - PumpControl.Reset(); - // Initialise status displays with zero values. StatusDisplay1.Reset(); StatusDisplay2.Reset(); @@ -357,6 +479,9 @@ namespace HC_APTBS.ViewModels if (def != null) StatusDisplay2.UpdateStatusWord(def, 0); } + // Load BIP-STATUS definitions (PSG5-PI pumps only; null = hide the control). + BipDisplay.LoadDefinition(pump.BipStatus); + // Start periodic senders for the new pump. _bench.StartMemoryRequestSender(); _bench.StartPumpSender(); @@ -366,10 +491,98 @@ namespace HC_APTBS.ViewModels GenerateReportCommand.NotifyCanExecuteChanged(); // Show voltage warning on 27V ↔ 13.5V transitions (WAlert27v equivalent). - CheckVoltageWarning(pump); + // Defer onto a later dispatcher frame so the modal ShowDialog() call cannot + // block the current frame — the one that carries the slider-enable paint. + App.Current.Dispatcher.BeginInvoke( + new Action(() => CheckVoltageWarning(pump)), + System.Windows.Threading.DispatcherPriority.Background); - // Start immobilizer unlock if this pump requires it (Ford VP44). - StartUnlockIfRequired(pump); + // Dismiss the previous pump's unlock snackbar immediately so the UI + // doesn't linger showing stale progress while we wait for CAN liveness. + CloseUnlockDialog(); + + _previousPump = pump; + + // Start immobilizer unlock if this pump requires it (Ford VP44), but + // first wait until the pump ECU is actually broadcasting on CAN so + // VerifyUnlock reads a real decoded TestUnlock value rather than the + // zero-initialised default (which Type 1 misinterprets as "unlocked"). + if (pump.UnlockType != 0) + { + _pumpLivenessCts = new CancellationTokenSource(); + _ = WaitForPumpCanThenUnlockAsync(pump, _pumpLivenessCts.Token); + } + } + + /// + /// Waits for the pump ECU to start broadcasting on CAN (or until a short + /// timeout elapses), then invokes on + /// the UI thread. Exists to ensure the unlock observer seeds from a real + /// decoded TestUnlock value, not the zero-initialised parameter default. + /// + private async Task WaitForPumpCanThenUnlockAsync(PumpDefinition pump, CancellationToken ct) + { + const int LivenessTimeoutMs = 10_000; + const int PostAliveGraceMs = 250; + + try + { + if (!_can.IsPumpAlive) + { + _log.Info(LogId, $"Waiting for CAN liveness for pump {pump.Id}..."); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnLiveness(bool alive) + { + if (alive) tcs.TrySetResult(true); + } + + _can.PumpLivenessChanged += OnLiveness; + try + { + // Race guard — pump may have become live between the + // IsPumpAlive check above and this subscription. + if (_can.IsPumpAlive) tcs.TrySetResult(true); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(LivenessTimeoutMs); + using var reg = timeoutCts.Token.Register(() => tcs.TrySetCanceled()); + + try { await tcs.Task.ConfigureAwait(false); } + catch (OperationCanceledException) + { + if (ct.IsCancellationRequested) return; // pump changed + _log.Warning(LogId, $"CAN liveness timeout for pump {pump.Id} — starting unlock anyway"); + } + } + finally + { + _can.PumpLivenessChanged -= OnLiveness; + } + } + + // Short grace delay so the full pump broadcast cycle has a chance + // to deliver every frame — in particular the one carrying the + // TestUnlock parameter — before the observer seeds from it. + try { await Task.Delay(PostAliveGraceMs, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + + // Verify we're still the current pump (a rapid switch may have + // cancelled our token before we reached this point). + if (!ReferenceEquals(_previousPump, pump)) return; + + await App.Current.Dispatcher.InvokeAsync(() => + { + if (!ReferenceEquals(_previousPump, pump)) return; + StartUnlockIfRequired(pump); + }); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _log.Warning(LogId, $"WaitForPumpCanThenUnlockAsync failed: {ex.Message}"); + } } // ── Immobilizer unlock ──────────────────────────────────────────────────── @@ -377,33 +590,43 @@ namespace HC_APTBS.ViewModels /// /// Starts the immobilizer unlock sequence in a non-modal window if the pump /// requires it (UnlockType != 0). Cancels any previously running unlock first. + /// The prior task is left to unwind + /// on its own token — we don't await it because + /// was already called synchronously in , and + /// re-stops the sender CTS idempotently + /// before creating a fresh one, so there is no sender race to guard against. /// private void StartUnlockIfRequired(PumpDefinition pump) { - // Cancel and close any previous unlock window. CloseUnlockDialog(); if (pump.UnlockType == 0) return; + // Start the 1 s background observer BEFORE kicking off UnlockAsync. + // It watches the CAN TestUnlock parameter and raises PumpUnlocked + // on every LOCKED → UNLOCKED transition, regardless of which code + // path triggered the unlock. Subscribers (the auto-test orchestrator + // and the unlock-progress dialog) latch onto this so they don't miss + // a fast unlock or an external manual unlock while still setting up. + _unlock.StartObserver(pump); + _unlockCts = new CancellationTokenSource(); CurrentUnlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc); CurrentUnlockVm.RequestClose += CloseUnlockDialog; // Start unlock in background — ViewModel tracks via event subscriptions. - var unlockTask = _unlock.UnlockAsync(pump, _unlockCts.Token); - _ = unlockTask.ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnFaulted); + _unlockTask = _unlock.UnlockAsync(pump, _unlockCts.Token); + _ = _unlockTask.ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnFaulted); } /// - /// Cancels any running unlock, stops persistent CAN senders, closes the - /// window, and disposes resources. Safe to call when no unlock is active. + /// Dismisses the unlock snackbar and disposes its ViewModel. Does NOT stop + /// the persistent CAN senders — those continue running until the next pump + /// selection (see ), because the Ford ECU re-locks + /// if the 0x300/0x700 flood stops. /// private void CloseUnlockDialog() { - // Stop the persistent CAN unlock senders (prevents re-lock until - // this point — only called when the pump is deselected). - _unlock.StopSenders(); - if (_unlockCts != null) { _unlockCts.Cancel(); @@ -411,6 +634,8 @@ namespace HC_APTBS.ViewModels _unlockCts = null; } + _unlockTask = null; + if (_unlockVm != null) { _unlockVm.RequestClose -= CloseUnlockDialog; @@ -425,7 +650,9 @@ namespace HC_APTBS.ViewModels [ObservableProperty] private string _canStatusText = string.Empty; /// True when the CAN bus adapter is connected. - [ObservableProperty] private bool _isCanConnected; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ConnectAndAutoTestCommand))] + private bool _isCanConnected; /// Connects to the CAN bus adapter. [RelayCommand] @@ -450,6 +677,13 @@ namespace HC_APTBS.ViewModels [RelayCommand] private void DisconnectCan() { + // Abort any in-flight "wait for CAN liveness" gate — without the bus + // up it will never complete, and we don't want it latching onto a + // future reconnect and firing an unlock against stale state. + _pumpLivenessCts?.Cancel(); + _pumpLivenessCts?.Dispose(); + _pumpLivenessCts = null; + _bench.StopElectronicMsgSender(); _bench.StopRelaySender(); _bench.StopMemoryRequestSender(); @@ -457,6 +691,11 @@ namespace HC_APTBS.ViewModels _can.Disconnect(); IsCanConnected = false; CanStatusText = _loc.GetString("Status.Disconnected"); + + // Clear the previous-pump handle so a fresh connect starts with an empty + // CAN parameter map (the adapter also drops its map on Disconnect, but we + // keep these two in sync explicitly). + _previousPump = null; } // ── Live bench readings ─────────────────────────────────────────────────── @@ -518,7 +757,9 @@ namespace HC_APTBS.ViewModels // ── Bench/pump connection status ────────────────────────────────────────── /// True when the bench controller is connected. - [ObservableProperty] private bool _isBenchConnected; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ConnectAndAutoTestCommand))] + private bool _isBenchConnected; /// True when the pump ECU is responding on CAN. [ObservableProperty] private bool _isPumpConnected; @@ -535,8 +776,24 @@ namespace HC_APTBS.ViewModels [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(StartTestCommand))] [NotifyCanExecuteChangedFor(nameof(StopTestCommand))] + [NotifyCanExecuteChangedFor(nameof(ConnectAndAutoTestCommand))] private bool _isTestRunning; + /// + /// Snackbar ViewModel for the Dashboard "Connect & Auto Test" sequence. + /// Non-null while a sequence is running or has just completed; the Dashboard + /// button uses this to toggle between "Auto Test" and "Cancel" appearances. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAutoTestActive))] + private AutoTestProgressViewModel? _autoTestProgress; + + /// + /// True while the Dashboard auto-test snackbar is visible. Bound by the + /// Dashboard button's style DataTrigger so it transforms into "Cancel". + /// + public bool IsAutoTestActive => AutoTestProgress != null; + /// True if the last test passed. [ObservableProperty] private bool _lastTestSuccess; @@ -554,6 +811,49 @@ namespace HC_APTBS.ViewModels /// Elapsed time since the current test started. Updated every second; retains last value when idle. [ObservableProperty] private TimeSpan _testElapsed; + // ── Commands: Dashboard auto-test ───────────────────────────────────────── + + /// + /// Runs the Dashboard's single-click auto-test sequence: connect K-Line, + /// read pump, unlock (if required), turn on bench, start oil pump, start test. + /// + [RelayCommand(CanExecute = nameof(CanAutoTest))] + private async Task ConnectAndAutoTestAsync() + { + var vm = new AutoTestProgressViewModel(_auto, _loc); + vm.RequestClose += () => App.Current.Dispatcher.Invoke(() => AutoTestProgress = null); + AutoTestProgress = vm; + ConnectAndAutoTestCommand.NotifyCanExecuteChanged(); + CancelAutoTestCommand.NotifyCanExecuteChanged(); + + try + { + await _auto.RunAsync(CancellationToken.None); + } + catch (Exception ex) + { + _log.Error(LogId, $"ConnectAndAutoTestAsync: {ex.Message}"); + } + finally + { + ConnectAndAutoTestCommand.NotifyCanExecuteChanged(); + CancelAutoTestCommand.NotifyCanExecuteChanged(); + } + } + + private bool CanAutoTest() + => !IsTestRunning + && !_auto.State.IsRunning() + && IsCanConnected + && IsBenchConnected + && !DashboardAlarms.HasCritical; + + /// Cancels the currently running auto-test sequence (if any). + [RelayCommand(CanExecute = nameof(CanCancelAutoTest))] + private void CancelAutoTest() => _auto.Cancel(); + + private bool CanCancelAutoTest() => _auto.State.IsRunning(); + // ── Commands: test ──────────────────────────────────────────────────────── /// Starts the test sequence for the current pump. @@ -562,19 +862,11 @@ namespace HC_APTBS.ViewModels { if (CurrentPump == null) return; - // Block test start if an unlock is still in progress. + // If a background unlock is still pending (e.g. K-Line is faulty and + // the sequence will never complete), cancel it so the operator can + // run tests anyway. Unlock is a best-effort pre-flight, not a gate. if (_unlockVm != null && !_unlockVm.IsComplete) - { - VerboseStatus = _loc.GetString("Status.UnlockInProgress"); - return; - } - - // Block test start if the unlock failed or was cancelled. - if (CurrentPump.UnlockType != 0 && _unlockVm?.IsSuccess != true) - { - VerboseStatus = _loc.GetString("Status.UnlockRequired"); - return; - } + CloseUnlockDialog(); _testCts = new CancellationTokenSource(); IsTestRunning = true; @@ -691,6 +983,7 @@ namespace HC_APTBS.ViewModels { if (_refreshTimer != null) _refreshTimer.Interval = TimeSpan.FromMilliseconds(_config.Settings.RefreshBenchInterfaceMs); + SettingsSaved?.Invoke(); } // ── Initialisation ──────────────────────────────────────────────────────── @@ -802,6 +1095,29 @@ namespace HC_APTBS.ViewModels if (def != null) StatusDisplay2.UpdateStatusWord(def, (int)empf3Param.Value); empf3Param.NeedsUpdate = false; } + + // Poll BIP status word ~once per second for PSG5-PI pumps with an active K-Line session. + if (CurrentPump.BipStatus != null + && _kwp.KLineState == KLineConnectionState.Connected + && ++_bipPollCounter >= BipPollEveryNTicks) + { + _bipPollCounter = 0; + _ = _kwp.ReadBipStatusAsync(); + } + } + + // Push live readings into every indicator on the active phase card so + // each gauge animates continuously through conditioning and measurement. + // TestPanel.ActivePhaseIndicators returns empty when no phase is active. + var liveIndicators = TestPanel.ActivePhaseIndicators; + if (liveIndicators.Count > 0) + { + for (int i = 0; i < liveIndicators.Count; i++) + { + var ind = liveIndicators[i]; + TestPanel.UpdateLiveIndicator(ind.ParameterName, + _bench.ReadParameter(ind.ParameterName)); + } } }