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();
}
}
}
}