Bundles several feature streams that have been iterating on the working tree: - Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the identification card, manual KWP write + transaction log, ROM/EEPROM dump card with progress banner and completion message, persisted custom-commands library, persisted EEPROM passwords library. New service primitives: IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync. Persistence mirrors the Clients XML pattern in two new files (custom_commands.xml, eeprom_passwords.xml). - Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and progress dialog VM, gated on dashboard alarms. - BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at 0x0106 via IKwpService.ReadBipStatusAsync; status definitions in BipStatusDefinition. - Tests page redesign: TestSectionCard + PhaseTileView replacing the old TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/ TestSectionView controls and their VMs. - Pump command sliders: Fluent thick-track style with overhang thumb, click-anywhere-and-drag, mouse-wheel adjustment. - Window startup: app.manifest declares PerMonitorV2 DPI awareness, MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and maximizes there (after the hook is in place) so the app fits the work area exactly on any display configuration. - Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and dump-functions reference docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
517 lines
23 KiB
C#
517 lines
23 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using HC_APTBS.Models;
|
|
|
|
namespace HC_APTBS.Services.Impl
|
|
{
|
|
/// <summary>
|
|
/// Linear state-machine orchestrator for the Dashboard "Connect & Auto Test"
|
|
/// button. Coordinates existing services (<see cref="IKwpService"/>,
|
|
/// <see cref="ICanService"/>, <see cref="IBenchService"/>, <see cref="IUnlockService"/>,
|
|
/// <see cref="IConfigurationService"/>) rather than re-implementing any protocol.
|
|
///
|
|
/// <para>Behavioural contract:</para>
|
|
/// <list type="bullet">
|
|
/// <item>Linear phases: <see cref="AutoTestState.Preflight"/> →
|
|
/// <see cref="AutoTestState.ConnectingKLine"/> →
|
|
/// <see cref="AutoTestState.ReadingPump"/> →
|
|
/// <see cref="AutoTestState.Unlocking"/> →
|
|
/// <see cref="AutoTestState.TurningOnBench"/> →
|
|
/// <see cref="AutoTestState.StartingOilPump"/> →
|
|
/// <see cref="AutoTestState.StartingTest"/> →
|
|
/// <see cref="AutoTestState.Running"/> →
|
|
/// <see cref="AutoTestState.Completed"/> or <see cref="AutoTestState.Aborted"/>.</item>
|
|
/// <item>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").</item>
|
|
/// <item>Unlocking is skipped when the selected pump's
|
|
/// <see cref="PumpDefinition.UnlockType"/> is 0.</item>
|
|
/// <item>When the oil-pump leak-check confirmation has not been disabled via
|
|
/// <see cref="AppSettings.AutoTestSkipsOilPumpConfirm"/>, the sequence aborts
|
|
/// with <see cref="AutoTestFailureReason.OilPumpNotConfirmed"/> before the
|
|
/// relay is energised.</item>
|
|
/// <item>Failure past <see cref="AutoTestStateExtensions.IsPastBenchOn(AutoTestState)"/>
|
|
/// triggers <see cref="IBenchService.RequestEmergencyStop"/>; earlier failures
|
|
/// close the K-Line and exit cleanly.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
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<IAutoTestHost> _hostFactory;
|
|
private const string LogId = "AutoTestOrch";
|
|
|
|
private CancellationTokenSource? _autoCts;
|
|
private AutoTestState _state = AutoTestState.Idle;
|
|
|
|
/// <summary>Latest test-phase name observed from <see cref="IBenchService.PhaseChanged"/>.</summary>
|
|
private string? _latestPhaseDetail;
|
|
|
|
/// <summary>Raised once a failure has been reported; guards against duplicate emits.</summary>
|
|
private bool _failureReported;
|
|
|
|
/// <inheritdoc/>
|
|
public AutoTestState State => _state;
|
|
|
|
/// <inheritdoc/>
|
|
public event Action<AutoTestState, string?>? StateChanged;
|
|
|
|
/// <inheritdoc/>
|
|
public event Action<AutoTestFailureReason, string>? Failed;
|
|
|
|
/// <summary>
|
|
/// Creates an orchestrator wired to the core services. The <paramref name="hostFactory"/>
|
|
/// resolves the <see cref="IAutoTestHost"/> lazily so that the orchestrator can be
|
|
/// constructed by the DI container at the same time as <c>MainViewModel</c> (which
|
|
/// implements <see cref="IAutoTestHost"/>) without creating a construction-order cycle.
|
|
/// </summary>
|
|
public AutoTestOrchestrator(
|
|
IKwpService kwp,
|
|
ICanService can,
|
|
IBenchService bench,
|
|
IUnlockService unlock,
|
|
IConfigurationService config,
|
|
IAppLogger log,
|
|
Func<IAutoTestHost> hostFactory)
|
|
{
|
|
_kwp = kwp;
|
|
_can = can;
|
|
_bench = bench;
|
|
_unlock = unlock;
|
|
_config = config;
|
|
_log = log;
|
|
_hostFactory = hostFactory;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Cancel() => _autoCts?.Cancel();
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<bool> 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<string, string> 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<bool>(
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts a lightweight task that polls the bench alarm word and aborts the
|
|
/// run if any critical bit transitions from clear to set.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|