using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using HC_APTBS.Models; using HC_APTBS.Services; using HC_APTBS.ViewModels.Dialogs; using HC_APTBS.ViewModels.Pages; using HC_APTBS.Views.Dialogs; namespace HC_APTBS.ViewModels { /// Identifies the top-level navigation page shown in the shell. public enum AppPage { /// Bench controls, flowmeter charts, encoder angles. Bench = 0, /// Pump manual control, DFI, status displays. Pump = 1, /// Test suite, live progress, results. Tests = 2, /// At-a-glance operator landing page: readings, connections, alarms, quick actions. Dashboard = 3, /// Application configuration: safety limits, PID, motor, report, K-Line, language. Settings = 4, /// Session-only history of completed test runs with detail view and PDF export. Results = 5, #if DEVELOPER_TOOLS /// Developer Tools page: raw K-Line / KWP custom command console. Debug builds only. Developer = 6 #endif } /// /// Root ViewModel for the application's main window. /// /// Responsibilities: /// /// CAN connection lifecycle. /// Bench status display (RPM, temperatures, flow measurements). /// Test start/stop and progress reporting. /// Relay toggle commands. /// Report generation trigger. /// /// /// Pump selection and K-Line ECU identification are delegated to /// . /// public sealed partial class MainViewModel : ObservableObject, IAutoTestHost { // ── Services ────────────────────────────────────────────────────────────── private readonly ICanService _can; private readonly IKwpService _kwp; private readonly IBenchService _bench; private readonly IConfigurationService _config; private readonly IPdfService _pdf; 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 . private DispatcherTimer? _testTimer; /// UTC start time of the current test; used by the timer to compute elapsed duration. private DateTime _testStartedUtc; // ── Unlock tracking ────────────────────────────────────────────────────── /// 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 /// display the same state that the floating dialog shows. Null while no /// unlock has been started for the current pump. /// public UnlockProgressViewModel? CurrentUnlockVm { get => _unlockVm; private set { if (!ReferenceEquals(_unlockVm, value)) { _unlockVm = value; OnPropertyChanged(); } } } /// 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. public PumpIdentificationViewModel PumpIdentification { get; } /// ViewModel for the DFI manage user control. public DfiManageViewModel DfiViewModel { get; } /// ViewModel for the test panel showing all test sections and phase cards. public TestPanelViewModel TestPanel { get; } /// ViewModel for the measurement results table. public ResultDisplayViewModel ResultDisplay { get; } /// ViewModel for the manual pump control sliders (FBKW, ME, PreIn). public PumpControlViewModel PumpControl { get; private set; } = null!; /// ViewModel for manual bench controls (direction, RPM, oil pump, counter). public BenchControlViewModel BenchControl { get; } /// ViewModel for the two flowmeter real-time charts (Q-Delivery, Q-Over). public FlowmeterChartViewModel FlowmeterChart { get; } = new(); /// ViewModel for the encoder angle monitoring display (PSG, INJ, Manual, Lock Angle). public AngleDisplayViewModel AngleDisplay { get; } /// ViewModel for the first pump status display (Status word). public StatusDisplayViewModel StatusDisplay1 { get; } = new(); /// 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; } /// ViewModel for the Dashboard's active-alarm list. public DashboardAlarmsViewModel DashboardAlarms { get; } /// Diagnostic Trouble Code list for the Pump page §3.b sub-section. public DtcListViewModel DtcList { get; } // ── Page ViewModels (thin façades over the child VMs above) ─────────────── /// Dashboard navigation page VM. public DashboardPageViewModel DashboardPage { get; private set; } = null!; /// Bench navigation page VM. public BenchPageViewModel BenchPage { get; private set; } = null!; /// Pump navigation page VM. public PumpPageViewModel PumpPage { get; private set; } = null!; /// Tests navigation page VM. public TestsPageViewModel TestsPage { get; private set; } = null!; /// Settings navigation page VM. public SettingsPageViewModel SettingsPage { get; private set; } = null!; /// Results navigation page VM (session-only test-run history). public ResultsPageViewModel ResultsPage { get; private set; } = null!; #if DEVELOPER_TOOLS /// Developer Tools page VM. Debug builds only — excluded from consumer Release builds. public Pages.DeveloperPageViewModel DeveloperPage { get; private set; } = null!; #endif // ── Navigation state ────────────────────────────────────────────────────── /// Currently selected top-level navigation page. [ObservableProperty] private AppPage _selectedPage = AppPage.Dashboard; // ── Constructor ─────────────────────────────────────────────────────────── /// /// Constructs the MainViewModel and wires all service events to UI-bound properties. /// Call after construction. /// public MainViewModel( ICanService canService, IKwpService kwpService, IBenchService benchService, IConfigurationService configService, IPdfService pdfService, IUnlockService unlockService, ILocalizationService localizationService, IAppLogger logger, IAutoTestOrchestrator autoTestOrchestrator) { _can = canService; _kwp = kwpService; _bench = benchService; _config = configService; _pdf = pdfService; _unlock = unlockService; _loc = localizationService; _log = logger; _auto = autoTestOrchestrator; _loc.LanguageChanged += RefreshLocalisedStrings; TestPanel = new TestPanelViewModel(localizationService); ResultDisplay = new ResultDisplayViewModel(localizationService); PumpIdentification = new PumpIdentificationViewModel(kwpService, configService, localizationService, logger); DfiViewModel = new DfiManageViewModel(kwpService, configService, localizationService); PumpControl = new PumpControlViewModel(benchService); BenchControl = new BenchControlViewModel(benchService, configService); AngleDisplay = new AngleDisplayViewModel(configService); DashboardAlarms = new DashboardAlarmsViewModel(configService.Settings.Alarms); DtcList = new DtcListViewModel(kwpService, localizationService, logger); BipDisplay = new BipDisplayViewModel(localizationService); // Page ViewModels are thin façades over the child VMs above; they hold a // reference back to this coordinator so page XAML can bind MainViewModel-owned // values via {Binding Root.X}. DashboardPage = new DashboardPageViewModel(this, canService, kwpService); BenchPage = new BenchPageViewModel(this, benchService, configService); PumpPage = new PumpPageViewModel(this, DtcList); TestsPage = new TestsPageViewModel(this, configService, localizationService); SettingsPage = new SettingsPageViewModel(configService, localizationService, logger); SettingsPage.SettingsSaved += OnSettingsSaved; ResultsPage = new ResultsPageViewModel(this, pdfService, configService, localizationService, logger); #if DEVELOPER_TOOLS DeveloperPage = new Pages.DeveloperPageViewModel(this, kwpService, configService, logger); #endif // 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)); // CAN status → status bar _can.StatusChanged += (msg, ok) => App.Current.Dispatcher.Invoke(() => { CanStatusText = msg; IsCanConnected = ok; }); // Bench/pump liveness → connection indicators _can.BenchLivenessChanged += alive => App.Current.Dispatcher.Invoke(() => IsBenchConnected = alive); _can.PumpLivenessChanged += alive => App.Current.Dispatcher.Invoke(() => IsPumpConnected = alive); // K-Line session state → indicator _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; _bench.PhaseChanged += phase => App.Current.Dispatcher.Invoke(() => { CurrentPhaseName = phase; TestPanel.SetActivePhase(phase); // Clear real-time plot traces at each new phase boundary. FlowmeterChart.Delivery.Clear(); FlowmeterChart.Over.Clear(); BenchPage.PressureTrace.P1.Clear(); BenchPage.PressureTrace.P2.Clear(); }); _bench.PhaseTimerTick += (section, remaining, total) => App.Current.Dispatcher.Invoke( () => TestPanel.ApplyPhaseTimerTick(section, remaining, total)); _bench.VerboseMessage += msg => App.Current.Dispatcher.Invoke(() => { VerboseStatus = msg; TestPanel.StatusText = msg; }); _bench.PsgSyncError += () => App.Current.Dispatcher.Invoke( () => ShowPsgSyncError()); _bench.PhaseCompleted += (phase, passed) => App.Current.Dispatcher.Invoke( () => TestPanel.SetPhaseResult(phase, passed)); _bench.ToleranceUpdated += (paramName, value, tolerance) => App.Current.Dispatcher.Invoke( () => { TestPanel.ApplyToleranceUpdate(paramName, value, tolerance); FlowmeterChart.SetTolerance(paramName, value, tolerance); if (paramName == BenchParameterNames.Pressure) BenchPage.PressureTrace.P1.SetTolerance(value, tolerance); else if (paramName == BenchParameterNames.AnalogSensor2) BenchPage.PressureTrace.P2.SetTolerance(value, tolerance); }); _bench.MeasurementSampled += (name, value) => App.Current.Dispatcher.Invoke(() => { if (name == BenchParameterNames.QDelivery) FlowmeterChart.Delivery.AddValue(value); else if (name == BenchParameterNames.QOver) FlowmeterChart.Over.AddValue(value); else if (name == BenchParameterNames.Pressure) BenchPage.PressureTrace.P1.AddValue(value); else if (name == BenchParameterNames.AnalogSensor2) BenchPage.PressureTrace.P2.AddValue(value); }); _bench.EmergencyStopTriggered += reason => App.Current.Dispatcher.Invoke(() => { VerboseStatus = string.Format(_loc.GetString("Error.EmergencyStop"), reason); }); _bench.StatusReactionTriggered += (bit, reaction, desc) => App.Current.Dispatcher.Invoke(() => { VerboseStatus = $"[STATUS] bit {bit} reaction={reaction}: {desc}"; }); // Angle display: lock angle and PSG zero from test phases _bench.LockAngleFaseReady += () => App.Current.Dispatcher.Invoke(() => { if (CurrentPump != null) CurrentPump.LockAngleResult = AngleDisplay.SetLockAngle(CurrentPump.LockAngle); }); _bench.PsgModeFaseReady += () => App.Current.Dispatcher.Invoke( () => AngleDisplay.SetPsgZeroFromTest()); // Unlock service status → verbose display _unlock.StatusChanged += msg => App.Current.Dispatcher.Invoke( () => VerboseStatus = msg); _unlock.UnlockCompleted += success => App.Current.Dispatcher.Invoke( () => _lastUnlockSucceeded = success); // Re-trigger unlock on any UNLOCKED → LOCKED transition (pump swap, power glitch, etc.) _unlock.PumpRelocked += OnPumpRelocked; // Safety-net: if a K-Line read completes and the pump is still LOCKED, re-run unlock. PumpIdentification.KlineReadCompleted += OnKlineReadCompleted; // KWP pump power-cycle callbacks kwpService.PumpDisconnectRequested += OnKwpDisconnectPump; kwpService.PumpReconnectRequested += OnKwpReconnectPump; } // ── Pump change handling ────────────────────────────────────────────────── /// 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(); DfiViewModel.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); // Load all test sections into the test panel. TestPanel.LoadAllTests(pump); // Register the pump's CAN parameters with the bus adapter. _can.AddParameters(pump.ParametersById); _can.RegisterPumpMessageIds(GetReceiveMessageIds(pump.ParametersById)); // Initialise status displays with zero values. StatusDisplay1.Reset(); StatusDisplay2.Reset(); if (pump.ParametersByName.TryGetValue(PumpParameterNames.Status, out var statusParam)) { var def = _config.LoadPumpStatus(statusParam.Type); if (def != null) StatusDisplay1.UpdateStatusWord(def, 0); } if (pump.ParametersByName.TryGetValue(PumpParameterNames.Empf3, out var empf3Param)) { var def = _config.LoadPumpStatus(empf3Param.Type); 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(); // Notify commands that depend on pump availability. StartTestCommand.NotifyCanExecuteChanged(); GenerateReportCommand.NotifyCanExecuteChanged(); // Show voltage warning on 27V ↔ 13.5V transitions (WAlert27v equivalent). // 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); // 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 ──────────────────────────────────────────────────── /// /// 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) { 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; CurrentUnlockVm.RequestRetry += () => RestartUnlockForSameSelection(pump); // Start unlock in background — ViewModel tracks via event subscriptions. _unlockTask = _unlock.UnlockAsync(pump, _unlockCts.Token); _ = _unlockTask.ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnFaulted); } /// /// Handles the UNLOCKED → LOCKED transition raised by the unlock observer on the CAN /// read thread. Re-runs the unlock flow against the current pump without touching CAN /// parameter registrations, the test panel, or bench senders (the pump model is unchanged). /// private void OnPumpRelocked() { App.Current.Dispatcher.BeginInvoke(new Action(() => { var pump = _previousPump; if (pump == null || pump.UnlockType == 0) return; // Skip if an unlock is already in-flight — the LOCKED frames that arrive // during Phase 1 of an ongoing unlock would otherwise cause infinite restarts. if (_unlockTask != null && !_unlockTask.IsCompleted) return; _log.Warning(LogId, $"Pump {pump.Id} transitioned UNLOCKED → LOCKED — re-triggering unlock"); RestartUnlockForSameSelection(pump); })); } /// /// Handles K-Line read completion. If the pump requires unlock and the observer reports /// LOCKED, re-runs the unlock flow. This is a safety net for the first-contact window /// where the CAN observer may not yet have received a frame from the new pump. /// private void OnKlineReadCompleted(string pumpId, string serial) { var pump = _previousPump; if (pump == null || !string.Equals(pump.Id, pumpId, StringComparison.OrdinalIgnoreCase)) return; if (pump.UnlockType == 0) return; if (_unlock.IsPumpUnlocked) return; // Skip if an unlock is already running. if (_unlockTask != null && !_unlockTask.IsCompleted) return; _log.Info(LogId, $"K-Line read completed on {pumpId}; observer reports LOCKED — re-triggering unlock"); RestartUnlockForSameSelection(pump); } /// /// Tears down the active unlock state and re-runs the liveness-wait → unlock pipeline /// against the already-selected pump. Used when the pump re-locks without a model change /// (physical swap of a same-ID unit, power instability, etc.). /// private void RestartUnlockForSameSelection(PumpDefinition pump) { _pumpLivenessCts?.Cancel(); _pumpLivenessCts?.Dispose(); _pumpLivenessCts = null; _unlockCts?.Cancel(); _unlock.StopSenders(); _unlock.StopObserver(); _lastUnlockSucceeded = false; _pumpLivenessCts = new CancellationTokenSource(); _ = WaitForPumpCanThenUnlockAsync(pump, _pumpLivenessCts.Token); } /// /// 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() { if (_unlockCts != null) { _unlockCts.Cancel(); _unlockCts.Dispose(); _unlockCts = null; } _unlockTask = null; if (_unlockVm != null) { _unlockVm.RequestClose -= CloseUnlockDialog; _unlockVm.Dispose(); CurrentUnlockVm = null; } } // ── CAN connection ──────────────────────────────────────────────────────── /// CAN bus status display text. [ObservableProperty] private string _canStatusText = string.Empty; /// True when the CAN bus adapter is connected. [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectAndAutoTestCommand))] private bool _isCanConnected; /// Connects to the CAN bus adapter. [RelayCommand] private void ConnectCan() { _can.SetParameters(_config.Bench.ParametersById); _can.RegisterBenchMessageIds(GetReceiveMessageIds(_config.Bench.ParametersById)); bool ok = _can.Connect(); CanStatusText = ok ? _loc.GetString("Status.Connected") : _loc.GetString("Status.ConnectionFailed"); IsCanConnected = ok; if (ok) { // ElectronicMsg keepalive (0x51) and relay bitmask (0x15) must // begin transmitting as soon as the CAN bus is up. _bench.StartElectronicMsgSender(); _bench.StartRelaySender(); } } /// Disconnects from the CAN bus adapter. [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(); _bench.StopPumpSender(); _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 ─────────────────────────────────────────────────── /// Bench motor speed (RPM), updated by the refresh timer. [ObservableProperty] private double _benchRpm; /// Oil inlet temperature T-in (°C). [ObservableProperty] private double _tempIn; /// Oil outlet temperature T-out (°C). [ObservableProperty] private double _tempOut; /// Auxiliary temperature T4 (°C). [ObservableProperty] private double _temp4; /// Oil tank temperature (°C). [ObservableProperty] private double _benchTemp; /// Fuel delivery measurement Q-delivery (cc/stroke). [ObservableProperty] private double _qDelivery; /// Fuel overflow/pilot measurement Q-over (cc/stroke). [ObservableProperty] private double _qOver; /// Bench oil pressure P1 (bar), sensor-calibrated. [ObservableProperty] private double _pressure; /// Analogue sensor 2 pressure P2 (bar), sensor-calibrated. [ObservableProperty] private double _pressure2; /// PSG encoder position value. [ObservableProperty] private double _psgEncoderValue; /// /// True when the Oil Pump relay is currently energised. Mirrored on each refresh /// tick from _config.Bench.Relays[RelayNames.OilPump] so the Tests page /// preconditions checklist can bind to it without walking the relay dictionary. /// [ObservableProperty] private bool _isOilPumpOn; // ── Pump live readings (from pump CAN parameters) ────────────────────────── /// Pump RPM reported by the ECU over CAN. [ObservableProperty] private double _pumpRpm; /// Pump internal temperature reported by the ECU over CAN. [ObservableProperty] private double _pumpTemp; /// Pump ME (metering) value from CAN. [ObservableProperty] private double _pumpMe; /// Pump FBkW (feedback) value from CAN. [ObservableProperty] private double _pumpFbkw; /// Pump T-ein (inlet timing) value from CAN, in microseconds. [ObservableProperty] private double _pumpTein; // ── Bench/pump connection status ────────────────────────────────────────── /// True when the bench controller is connected. [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectAndAutoTestCommand))] private bool _isBenchConnected; /// True when the pump ECU is responding on CAN. [ObservableProperty] private bool _isPumpConnected; /// True when oil circulation has been detected. [ObservableProperty] private bool _isOilCirculating; /// Current K-Line session state (Disconnected / Connected / Failed). [ObservableProperty] private KLineConnectionState _kLineState = KLineConnectionState.Disconnected; // ── Test status ─────────────────────────────────────────────────────────── /// True while a test sequence is running. [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; /// Name of the currently executing test phase. [ObservableProperty] private string _currentPhaseName = string.Empty; /// Verbose status message from bench/test operations. [ObservableProperty] private string _verboseStatus = string.Empty; // ── Test saved state ────────────────────────────────────────────────────── /// True when the current test results have been saved to a report. [ObservableProperty] private bool _isTestSaved = true; /// 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. [RelayCommand(CanExecute = nameof(CanStartTest))] private async Task StartTestAsync() { if (CurrentPump == null) return; // 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) CloseUnlockDialog(); _testCts = new CancellationTokenSource(); IsTestRunning = true; IsTestSaved = false; await _bench.RunTestsAsync(CurrentPump, _testCts.Token); } private bool CanStartTest() => CurrentPump != null && !IsTestRunning && IsCanConnected; /// Requests a controlled stop of the running test. [RelayCommand(CanExecute = nameof(CanStopTest))] private void StopTest() { _bench.StopTests(); _testCts?.Cancel(); } private bool CanStopTest() => IsTestRunning; /// /// Operator-initiated emergency stop from the Dashboard. /// Zeros the motor, zeros pump parameters, and cancels any running test. /// [RelayCommand] private void EmergencyStop() { _bench.RequestEmergencyStop("Operator pressed E-Stop on Dashboard"); _testCts?.Cancel(); } // ── Commands: relay toggles ─────────────────────────────────────────────── /// Toggles the electronic relay (pump solenoid power). [RelayCommand] private void ToggleElectronic() => ToggleRelay(RelayNames.Electronic); /// Toggles the oil pump relay. [RelayCommand] private void ToggleOilPump() => ToggleRelay(RelayNames.OilPump); /// Toggles the deposit cooler relay. [RelayCommand] private void ToggleDepositCooler() => ToggleRelay(RelayNames.DepositCooler); /// Toggles the deposit heater relay. [RelayCommand] private void ToggleDepositHeater() => ToggleRelay(RelayNames.DepositHeater); private void ToggleRelay(string name) { if (!_config.Bench.Relays.TryGetValue(name, out var relay)) return; _bench.SetRelay(name, !relay.State); } // ── Commands: report ────────────────────────────────────────────────────── /// Generates and opens the PDF report for the last completed test. [RelayCommand(CanExecute = nameof(CanGenerateReport))] private void GenerateReport() { if (CurrentPump == null) return; // Step 1: Authenticate operator. var authVm = new UserCheckViewModel(_config, _loc, _lastAuthenticatedUser); var authDlg = new UserCheckDialog(authVm) { Owner = Application.Current.MainWindow }; authDlg.ShowDialog(); if (!authVm.Accepted) return; _lastAuthenticatedUser = authVm.AuthenticatedUser; // Step 2: Collect report details (client, company, observations). var reportVm = new ReportViewModel(_config) { OperatorName = authVm.AuthenticatedUser }; var reportDlg = new ReportDialog(reportVm) { Owner = Application.Current.MainWindow }; reportDlg.ShowDialog(); if (!reportVm.Accepted) return; try { string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); string path = _pdf.GenerateReport( CurrentPump, reportVm.OperatorName, reportVm.SelectedClientName, desktop, clientInfo: reportVm.ClientInfo, observations: reportVm.Observations); _log.Info(LogId, $"Report saved: {path}"); IsTestSaved = true; System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); } catch (Exception ex) { _log.Error(LogId, $"GenerateReport: {ex.Message}"); MessageBox.Show(string.Format(_loc.GetString("Error.ReportGeneration"), ex.Message), _loc.GetString("Error.ReportTitle"), MessageBoxButton.OK, MessageBoxImage.Error); } } private bool CanGenerateReport() => CurrentPump != null && !IsTestRunning && CurrentPump.Tests.Count > 0; /// Refreshes all ViewModel-cached localised strings after a language change. private void RefreshLocalisedStrings() { CanStatusText = IsCanConnected ? _loc.GetString("Status.Connected") : _loc.GetString("Status.Disconnected"); } /// /// Reseeds settings-dependent runtime state after the operator saves on the Settings page. /// Currently only the bench refresh-timer interval needs re-application. /// private void OnSettingsSaved() { if (_refreshTimer != null) _refreshTimer.Interval = TimeSpan.FromMilliseconds(_config.Settings.RefreshBenchInterfaceMs); SettingsSaved?.Invoke(); } // ── Initialisation ──────────────────────────────────────────────────────── /// /// Loads pump IDs, wires the refresh timer, and connects to the CAN bus. /// Call once from the View after construction. /// public async Task InitialiseAsync() { // Populate the pump selector. PumpIdentification.LoadPumpIds(); // Connect CAN bus. _can.SetParameters(_config.Bench.ParametersById); _can.RegisterBenchMessageIds(GetReceiveMessageIds(_config.Bench.ParametersById)); bool canOk = _can.Connect(); if (canOk) { _bench.StartElectronicMsgSender(); _bench.StartRelaySender(); } // Start the UI refresh timer. StartRefreshTimer(); _log.Info(LogId, "MainViewModel initialised."); await Task.CompletedTask; } // ── Refresh timer ───────────────────────────────────────────────────────── private System.Windows.Threading.DispatcherTimer? _refreshTimer; private void StartRefreshTimer() { _refreshTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(_config.Settings.RefreshBenchInterfaceMs) }; _refreshTimer.Tick += OnRefreshTick; _refreshTimer.Start(); } private void OnRefreshTick(object? sender, EventArgs e) { // Read all bench parameters that have been updated by the CAN receive thread. BenchRpm = _bench.ReadBenchParameter(BenchParameterNames.BenchRpm); TempIn = _bench.ReadBenchParameter(BenchParameterNames.TempIn); TempOut = _bench.ReadBenchParameter(BenchParameterNames.TempOut); Temp4 = _bench.ReadBenchParameter(BenchParameterNames.Temp4); BenchTemp = _bench.ReadBenchParameter(BenchParameterNames.Temp); QDelivery = _bench.ReadBenchParameter(BenchParameterNames.QDelivery); QOver = _bench.ReadBenchParameter(BenchParameterNames.QOver); PsgEncoderValue = _bench.ReadBenchParameter(BenchParameterNames.PsgEncoderValue); // Apply analogue sensor calibration for pressure channels. double rawP1 = _bench.ReadBenchParameter(BenchParameterNames.Pressure); Pressure = _config.Settings.Sensors.TryGetValue(1, out var s1) ? s1.GetValueFromRaw(rawP1) : rawP1; double rawP2 = _bench.ReadBenchParameter(BenchParameterNames.AnalogSensor2); Pressure2 = _config.Settings.Sensors.TryGetValue(2, out var s2) ? s2.GetValueFromRaw(rawP2) : rawP2; // Feed the angle display with all three encoder channels + status. AngleDisplay.Update( PsgEncoderValue, _bench.ReadBenchParameter(BenchParameterNames.PsgEncoderWorking) == 1, _bench.ReadBenchParameter(BenchParameterNames.InjEncoderValue), _bench.ReadBenchParameter(BenchParameterNames.InjEncoderWorking) == 1, _bench.ReadBenchParameter(BenchParameterNames.ManualEncoderValue), BenchRpm, BenchControl.IsDirectionRight); // Feed flowmeter charts and refresh bench controls. FlowmeterChart.AddSamples(QDelivery, QOver); BenchControl.RefreshFromTick(); // Mirror the oil pump relay state for the Tests page preconditions checklist. IsOilPumpOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State; // Feed page-scoped Bench VMs (pressure trace + interlock banner). BenchPage.RefreshFromTick(); // Refresh Dashboard's active-alarm list from the bench alarm bitmask. DashboardAlarms.Update((int)_bench.ReadBenchParameter(BenchParameterNames.Alarms)); if (CurrentPump != null) { PumpRpm = _bench.ReadPumpParameter(PumpParameterNames.Rpm); PumpTemp = _bench.ReadPumpParameter(PumpParameterNames.Temp); PumpMe = _bench.ReadPumpParameter(PumpParameterNames.Me); PumpFbkw = _bench.ReadPumpParameter(PumpParameterNames.Fbkw); PumpTein = _bench.ReadPumpParameter(PumpParameterNames.Tein); // Update status display 1 (Status word) when the CAN receiver flags an update. if (CurrentPump.ParametersByName.TryGetValue(PumpParameterNames.Status, out var statusParam) && statusParam.NeedsUpdate) { var def = _config.LoadPumpStatus(statusParam.Type); if (def != null) StatusDisplay1.UpdateStatusWord(def, (int)statusParam.Value); statusParam.NeedsUpdate = false; } // Update status display 2 (Empf3 word). if (CurrentPump.ParametersByName.TryGetValue(PumpParameterNames.Empf3, out var empf3Param) && empf3Param.NeedsUpdate) { var def = _config.LoadPumpStatus(empf3Param.Type); 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)); } } } // ── Service event handlers ──────────────────────────────────────────────── private void OnTestStarted() => App.Current.Dispatcher.Invoke(() => { IsTestRunning = true; VerboseStatus = _loc.GetString("Test.Started"); _testStartedUtc = DateTime.UtcNow; TestElapsed = TimeSpan.Zero; _testTimer = new DispatcherTimer( TimeSpan.FromSeconds(1), DispatcherPriority.Normal, (_, _) => TestElapsed = DateTime.UtcNow - _testStartedUtc, App.Current.Dispatcher); TestPanel.IsRunning = true; TestPanel.ResetResults(); ResultDisplay.Clear(); PumpControl.Reset(); _bench.StartPumpSender(); _log.Info(LogId, "Test sequence started."); }); private void OnTestFinished(bool interrupted, bool success) => App.Current.Dispatcher.Invoke(() => { _testTimer?.Stop(); _testTimer = null; IsTestRunning = false; LastTestSuccess = !interrupted && success; VerboseStatus = interrupted ? _loc.GetString("Test.Stopped") : (success ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail")); TestPanel.IsRunning = false; TestPanel.ClearPhaseTimer(); _bench.StopPumpSender(); StartTestCommand.NotifyCanExecuteChanged(); StopTestCommand.NotifyCanExecuteChanged(); GenerateReportCommand.NotifyCanExecuteChanged(); // Populate results table from all completed tests. if (!interrupted && CurrentPump != null) ResultDisplay.LoadAllResults(CurrentPump.Tests); // Capture a session-only history entry (Results page §5) — covers normal // and interrupted completions. Snapshot is deep-cloned so later runs // cannot mutate this entry's data. if (CurrentPump != null) ResultsPage.CaptureRun(CurrentPump, interrupted, success); _log.Info(LogId, $"Test finished — interrupted={interrupted}, success={success}"); }); private void OnKwpDisconnectPump() => App.Current.Dispatcher.Invoke(() => { _bench.SetRelay(RelayNames.Electronic, false); }); private void OnKwpReconnectPump() => App.Current.Dispatcher.Invoke(() => { _bench.SetRelay(RelayNames.Electronic, true); }); private void ShowPsgSyncError() => MessageBox.Show( _loc.GetString("Error.PsgSync"), _loc.GetString("Error.PsgTitle"), MessageBoxButton.OK, MessageBoxImage.Warning); // ── Voltage warning ──────────────────────────────────────────────────────── /// /// Shows a voltage warning dialog when the pump supply voltage requirement /// changes between 27 V and 13.5 V (or vice versa). Only triggers on /// state transitions, matching the old WAlert27v behaviour. /// private void CheckVoltageWarning(PumpDefinition pump) { bool is27V = !string.IsNullOrEmpty(pump.Tension) && pump.Tension.Contains("27"); if (is27V && !_lastPumpWas27V) { var vm = new Dialogs.VoltageWarningViewModel("27 V"); var dlg = new Views.Dialogs.VoltageWarningDialog(vm) { Owner = Application.Current.MainWindow }; dlg.ShowDialog(); _lastPumpWas27V = true; } else if (!is27V && _lastPumpWas27V) { var vm = new Dialogs.VoltageWarningViewModel("13.5 V"); var dlg = new Views.Dialogs.VoltageWarningDialog(vm) { Owner = Application.Current.MainWindow }; dlg.ShowDialog(); _lastPumpWas27V = false; } } // ── Helpers ─────────────────────────────────────────────────────────────── /// /// Returns only the message IDs that contain at least one receive parameter. /// Transmit-only IDs (RPM command, ElectronicMsg, etc.) are excluded because /// they are frames we send, not frames the remote device sends to us. /// private static HashSet GetReceiveMessageIds( Dictionary> parametersById) { var ids = new HashSet(); foreach (var kv in parametersById) { if (kv.Value.Any(p => p.IsReceive)) ids.Add(kv.Key); } return ids; } } }