using System; using System.Threading; using System.Threading.Tasks; using HC_APTBS.Models; namespace HC_APTBS.Services.Impl { /// /// Linear state-machine orchestrator for the Dashboard "Connect & Auto Test" /// button. Coordinates existing services (, /// , , , /// ) rather than re-implementing any protocol. /// /// Behavioural contract: /// /// Linear phases: → /// → /// → /// → /// → /// → /// → /// → /// or . /// Connecting/Reading are skipped when the K-Line is already open and a pump /// is already selected (fast-path for "re-run on the same pump"). /// Unlocking is skipped when the selected pump's /// is 0. /// When the oil-pump leak-check confirmation has not been disabled via /// , the sequence aborts /// with before the /// relay is energised. /// Failure past /// triggers ; earlier failures /// close the K-Line and exit cleanly. /// /// public sealed class AutoTestOrchestrator : IAutoTestOrchestrator { private readonly IKwpService _kwp; private readonly ICanService _can; private readonly IBenchService _bench; private readonly IUnlockService _unlock; private readonly IConfigurationService _config; private readonly IAppLogger _log; private readonly Func _hostFactory; private const string LogId = "AutoTestOrch"; private CancellationTokenSource? _autoCts; private AutoTestState _state = AutoTestState.Idle; /// Latest test-phase name observed from . private string? _latestPhaseDetail; /// Raised once a failure has been reported; guards against duplicate emits. private bool _failureReported; /// public AutoTestState State => _state; /// public event Action? StateChanged; /// public event Action? Failed; /// /// Creates an orchestrator wired to the core services. The /// resolves the lazily so that the orchestrator can be /// constructed by the DI container at the same time as MainViewModel (which /// implements ) without creating a construction-order cycle. /// public AutoTestOrchestrator( IKwpService kwp, ICanService can, IBenchService bench, IUnlockService unlock, IConfigurationService config, IAppLogger log, Func hostFactory) { _kwp = kwp; _can = can; _bench = bench; _unlock = unlock; _config = config; _log = log; _hostFactory = hostFactory; } /// public void Cancel() => _autoCts?.Cancel(); /// public async Task RunAsync(CancellationToken ct) { if (_state.IsRunning()) { _log.Warning(LogId, "RunAsync called while a sequence is already in progress"); return false; } _failureReported = false; _latestPhaseDetail = null; _autoCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var token = _autoCts.Token; // ── Abort watchers ──────────────────────────────────────────────────── // Subscribe these up-front so any mid-sequence hardware drop-out trips // the CTS immediately. Unsubscribed in the finally block. void OnBenchLiveness(bool alive) { if (alive) return; if (_state == AutoTestState.Idle || _state == AutoTestState.Preflight) return; ReportFailure(AutoTestFailureReason.BenchCanLost, "Bench CAN liveness lost"); _autoCts?.Cancel(); } void OnKLineState(KLineConnectionState st) { if (st != KLineConnectionState.Failed) return; // Only treat as loss when K-Line is actually required by the current phase. if (_state is AutoTestState.ConnectingKLine or AutoTestState.ReadingPump or AutoTestState.Unlocking) { ReportFailure(AutoTestFailureReason.KLineLost, "K-Line session dropped"); _autoCts?.Cancel(); } } // Snapshot the alarm mask on entry; any transition that flips a critical bit // to "set" aborts the run. Uses the bench alarm parameter directly so we stay // decoupled from DashboardAlarmsViewModel. int initialMask = ReadAlarmMask(); int criticalMask = BuildCriticalAlarmBitmask(); _can.BenchLivenessChanged += OnBenchLiveness; _kwp.KLineStateChanged += OnKLineState; _bench.PhaseChanged += OnPhaseChanged; using var alarmWatch = StartAlarmWatchdog(token, initialMask, criticalMask); try { // ── Preflight ──────────────────────────────────────────────────── SetState(AutoTestState.Preflight); if (!_can.IsConnected) { ReportFailure(AutoTestFailureReason.PreflightDenied, "CAN bus not connected"); return false; } if ((ReadAlarmMask() & criticalMask) != 0) { ReportFailure(AutoTestFailureReason.PreflightDenied, "Critical alarm already active"); return false; } token.ThrowIfCancellationRequested(); var host = _hostFactory(); bool klineAlreadyOpen = _kwp.KLineState == KLineConnectionState.Connected && host.CurrentPump != null; // ── ConnectingKLine ────────────────────────────────────────────── if (!klineAlreadyOpen) { SetState(AutoTestState.ConnectingKLine); string? port = _kwp.DetectKLinePort(); if (string.IsNullOrEmpty(port)) { ReportFailure(AutoTestFailureReason.KLineConnectFailed, "FTDI adapter not found"); return false; } // ConnectAsync opens the session and starts the keep-alive loop. // If the session is already open the service returns immediately. try { await _kwp.ConnectAsync(port, token).ConfigureAwait(true); } catch (OperationCanceledException) { throw; } catch (Exception ex) { ReportFailure(AutoTestFailureReason.KLineConnectFailed, $"ConnectAsync failed: {ex.Message}"); return false; } } // ── ReadingPump ────────────────────────────────────────────────── if (!klineAlreadyOpen) { SetState(AutoTestState.ReadingPump); int version = host.CurrentPump?.KwpVersion ?? 0; string? port = _kwp.ConnectedPort ?? _kwp.DetectKLinePort(); if (string.IsNullOrEmpty(port)) { ReportFailure(AutoTestFailureReason.KLineConnectFailed, "FTDI adapter disappeared before read"); return false; } // Forward ReadAllInfoAsync percentage ticks to the snackbar. void OnProgress(int pct, string _) => RaiseStateChanged(AutoTestState.ReadingPump, pct.ToString()); _kwp.ProgressChanged += OnProgress; System.Collections.Generic.Dictionary info; try { info = await _kwp.ReadAllInfoAsync(port, version, token).ConfigureAwait(true); } catch (OperationCanceledException) { throw; } catch (Exception ex) { ReportFailure(AutoTestFailureReason.ReadFailed, $"ReadAllInfoAsync threw: {ex.Message}"); return false; } finally { _kwp.ProgressChanged -= OnProgress; } if (!info.TryGetValue(KlineKeys.Result, out var result) || result != "1") { ReportFailure(AutoTestFailureReason.ReadFailed, info.TryGetValue(KlineKeys.ConnectError, out var err) ? err : "Read result=0"); return false; } // The KwpService.PumpIdentified event fires mid-read on the background // thread and is marshalled to the UI thread by PumpIdentificationViewModel, // which sets SelectedPumpId → CurrentPump via a synchronous side-effect // chain. After await completes, CurrentPump is therefore populated — // unless the K-Line pump ID was not in pumps.xml. if (host.CurrentPump == null) { ReportFailure(AutoTestFailureReason.PumpNotRecognized, info.TryGetValue(KlineKeys.PumpId, out var kid) ? $"Pump ID '{kid}' not in database" : "Pump ID not recognised"); return false; } } var pump = host.CurrentPump; if (pump == null) { ReportFailure(AutoTestFailureReason.PumpNotRecognized, "No pump selected"); return false; } token.ThrowIfCancellationRequested(); // ── Unlocking ──────────────────────────────────────────────────── // When the pump was auto-selected during the read, MainViewModel.OnPumpChanged // already started UnlockService.UnlockAsync AND the 1 s observer in the // background. We wait on whichever fires first: // - PumpUnlocked (observer confirmed the CAN TestUnlock param flipped), // - UnlockCompleted (the service's own UnlockAsync finished). // The observer race-guards against the case where the pump auto-unlocks // (fast unlock shortcut or an external manual unlock) before we subscribe. if (pump.UnlockType != 0) { SetState(AutoTestState.Unlocking); // Race-guard short-circuit: if the observer already latched an // unlocked state (fast unlock finished while we were still doing // the K-Line read), skip straight past the Unlocking wait. if (_unlock.IsPumpUnlocked) { RaiseStateChanged(AutoTestState.Unlocking, "Pump already unlocked"); } else { var unlockTcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); void OnUnlockStatus(string msg) => RaiseStateChanged(AutoTestState.Unlocking, msg); void OnUnlockCompleted(bool success) => unlockTcs.TrySetResult(success); // Observer fires as soon as the CAN TestUnlock parameter reports // unlocked — this covers fast unlock and external unlocks that // would otherwise only be observed when UnlockAsync itself finishes. void OnPumpUnlocked() => unlockTcs.TrySetResult(true); _unlock.StatusChanged += OnUnlockStatus; _unlock.UnlockCompleted += OnUnlockCompleted; _unlock.PumpUnlocked += OnPumpUnlocked; using var ctReg = token.Register(() => unlockTcs.TrySetCanceled()); bool unlocked; try { // Re-check after subscribing to close the subscribe-vs-fire race. if (_unlock.IsPumpUnlocked) unlockTcs.TrySetResult(true); unlocked = await unlockTcs.Task.ConfigureAwait(true); } finally { _unlock.StatusChanged -= OnUnlockStatus; _unlock.UnlockCompleted -= OnUnlockCompleted; _unlock.PumpUnlocked -= OnPumpUnlocked; } if (!unlocked) { ReportFailure(AutoTestFailureReason.UnlockFailed, "Unlock verification failed"); return false; } } } token.ThrowIfCancellationRequested(); // ── TurningOnBench ─────────────────────────────────────────────── // Past this point any failure must request an emergency stop. SetState(AutoTestState.TurningOnBench); _bench.SetRelay(RelayNames.Electronic, true); _bench.SetRpm(0); token.ThrowIfCancellationRequested(); // ── StartingOilPump ────────────────────────────────────────────── // Delegate to the UI host: handles already-on short-circuit, the // autoskip setting, and the leak-check dialog. Returns false only // when the operator actively cancels the confirmation dialog. SetState(AutoTestState.StartingOilPump); bool oilPumpReady = await _hostFactory() .EnsureOilPumpOnAsync(_config.Settings.AutoTestSkipsOilPumpConfirm) .ConfigureAwait(true); if (!oilPumpReady) { ReportFailure(AutoTestFailureReason.OilPumpNotConfirmed, string.Empty); return false; } token.ThrowIfCancellationRequested(); // ── StartingTest / Running ─────────────────────────────────────── SetState(AutoTestState.StartingTest); var testTcs = new TaskCompletionSource<(bool interrupted, bool success)>( TaskCreationOptions.RunContinuationsAsynchronously); void OnTestStarted() => SetState(AutoTestState.Running, _latestPhaseDetail); void OnTestFinished(bool interrupted, bool success) => testTcs.TrySetResult((interrupted, success)); void OnVerbose(string msg) => RaiseStateChanged(AutoTestState.Running, msg); _bench.TestStarted += OnTestStarted; _bench.TestFinished += OnTestFinished; _bench.VerboseMessage += OnVerbose; try { // RunTestsAsync runs its sequence on a background task internally; // we wait on TestFinished so we observe success/interruption state. await _bench.RunTestsAsync(pump, token).ConfigureAwait(true); // RunTestsAsync returns once the background task completes, but the // TestFinished event is the authoritative source for interrupted/success. var result = await testTcs.Task.ConfigureAwait(true); if (result.interrupted) { ReportFailure(AutoTestFailureReason.TestInterrupted, "Test interrupted"); return false; } if (!result.success) { ReportFailure(AutoTestFailureReason.TestFailed, "Test failed"); return false; } } finally { _bench.TestStarted -= OnTestStarted; _bench.TestFinished -= OnTestFinished; _bench.VerboseMessage -= OnVerbose; } SetState(AutoTestState.Completed); return true; } catch (OperationCanceledException) { if (!_failureReported) ReportFailure(AutoTestFailureReason.UserCancelled, "Cancelled"); return false; } catch (Exception ex) { _log.Error(LogId, $"Unexpected exception: {ex}"); if (!_failureReported) ReportFailure(AutoTestFailureReason.Unexpected, ex.Message); return false; } finally { _can.BenchLivenessChanged -= OnBenchLiveness; _kwp.KLineStateChanged -= OnKLineState; _bench.PhaseChanged -= OnPhaseChanged; // E-stop only if we failed past bench-on. if (_state.IsPastBenchOn() && _state != AutoTestState.Completed) { try { _bench.RequestEmergencyStop("Auto-test aborted"); } catch (Exception ex) { _log.Error(LogId, $"E-stop failed: {ex.Message}"); } } if (_state != AutoTestState.Completed && _state != AutoTestState.Aborted) SetState(AutoTestState.Aborted); _autoCts?.Dispose(); _autoCts = null; } } // ── Helpers ────────────────────────────────────────────────────────────── private void SetState(AutoTestState next, string? detail = null) { _state = next; RaiseStateChanged(next, detail); } private void RaiseStateChanged(AutoTestState s, string? detail) => StateChanged?.Invoke(s, detail); private void ReportFailure(AutoTestFailureReason reason, string message) { if (_failureReported) return; _failureReported = true; _log.Warning(LogId, $"Failed: {reason} — {message}"); Failed?.Invoke(reason, message); } private void OnPhaseChanged(string phaseName) { _latestPhaseDetail = phaseName; if (_state == AutoTestState.Running) RaiseStateChanged(AutoTestState.Running, phaseName); } private int ReadAlarmMask() { try { return (int)_bench.ReadBenchParameter(BenchParameterNames.Alarms); } catch { return 0; } } private int BuildCriticalAlarmBitmask() { int mask = 0; foreach (var a in _config.Settings.Alarms) if (a.IsCritical) mask |= 1 << a.Bit; return mask; } /// /// Starts a lightweight task that polls the bench alarm word and aborts the /// run if any critical bit transitions from clear to set. /// private IDisposable StartAlarmWatchdog(CancellationToken token, int initialMask, int criticalMask) { var cts = CancellationTokenSource.CreateLinkedTokenSource(token); int lastMask = initialMask; _ = Task.Run(async () => { try { while (!cts.IsCancellationRequested) { await Task.Delay(250, cts.Token).ConfigureAwait(false); int now = ReadAlarmMask(); int newlySet = now & ~lastMask; lastMask = now; if ((newlySet & criticalMask) != 0) { ReportFailure(AutoTestFailureReason.AlarmTriggered, "Critical alarm transitioned active"); _autoCts?.Cancel(); return; } } } catch (OperationCanceledException) { /* expected */ } }, cts.Token); return new Disposer(cts); } private sealed class Disposer : IDisposable { private readonly CancellationTokenSource _cts; public Disposer(CancellationTokenSource cts) { _cts = cts; } public void Dispose() { try { _cts.Cancel(); } catch { } _cts.Dispose(); } } } }