feat: add Ford VP44 unlock progress dialog, K-Line fast unlock, localization, safety dialogs, and settings
Unlock progress UI:
- UnlockProgressDialog with dark-themed progress ring, phase indicator, elapsed
time, and cancel/close buttons (non-modal, draggable borderless window)
- UnlockProgressViewModel with event-driven progress tracking via IUnlockService
- Triggers on pump selection (manual or K-Line auto-detect), not test start
UnlockService rewrite:
- Persistent CAN senders that outlive the unlock sequence (StopSenders on pump change)
- Concurrent K-Line fast unlock: awaits session Connected, sends RAM timer shortcut
({02 88 02 03 A8 01 00}), verifies via CAN TestUnlock before skipping wait
- Fix Type 1 verification (Value == 0 means unlocked, was inverted)
K-Line fast unlock support:
- IKwpService.TryFastUnlockAsync / KwpService implementation
Additional features:
- ILocalizationService with ES/EN resource dictionaries and runtime switching
- Safety dialogs: VoltageWarning, OilPumpConfirm, RpmSafetyWarning
- SettingsDialog for app configuration
- BenchService enhancements, ConfigurationService improvements, PDF report updates
- All UI strings localized via DynamicResource
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,31 +7,43 @@ namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements the immobilizer unlock sequence for Ford VP44 pump ECUs.
|
||||
/// The unlock has two phases:
|
||||
/// <para>The CAN flood messages must start before unlocking and continue running
|
||||
/// after unlock completes — stopping them causes the pump to re-lock. Call
|
||||
/// <see cref="StopSenders"/> only when the pump is deselected.</para>
|
||||
/// <list type="number">
|
||||
/// <item>Continuous CAN message sends for ~10 minutes (600.5 s)</item>
|
||||
/// <item>A state-machine handshake that cycles through command bytes on 0x700</item>
|
||||
/// <item>Start persistent CAN senders (run until explicitly stopped)</item>
|
||||
/// <item>Begin the 600 s CAN wait with progress reporting</item>
|
||||
/// <item>In parallel, wait for K-Line to become Connected, then try the fast
|
||||
/// unlock (RAM timer shortcut) — if the pump verifies unlocked, cancel
|
||||
/// the remaining wait</item>
|
||||
/// <item>TestUnlock state-machine handshake on 0x700</item>
|
||||
/// <item>Verify via CAN TestUnlock parameter</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class UnlockService : IUnlockService
|
||||
{
|
||||
private readonly ICanService _can;
|
||||
private readonly IKwpService _kwp;
|
||||
private readonly IAppLogger _log;
|
||||
private const string LogId = "UnlockService";
|
||||
|
||||
/// <summary>Total duration of the Phase 1 continuous send (milliseconds).</summary>
|
||||
/// <summary>Total duration of the Phase 1 wait (milliseconds).</summary>
|
||||
private const int UnlockDurationMs = 600_500;
|
||||
|
||||
/// <summary>CTS for the persistent CAN senders — lives beyond <see cref="UnlockAsync"/>.</summary>
|
||||
private CancellationTokenSource? _senderCts;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<bool>? UnlockCompleted;
|
||||
|
||||
/// <summary>Creates the unlock service wired to the CAN bus.</summary>
|
||||
public UnlockService(ICanService canService, IAppLogger logger)
|
||||
/// <summary>Creates the unlock service wired to the CAN and K-Line buses.</summary>
|
||||
public UnlockService(ICanService canService, IKwpService kwpService, IAppLogger logger)
|
||||
{
|
||||
_can = canService;
|
||||
_kwp = kwpService;
|
||||
_log = logger;
|
||||
}
|
||||
|
||||
@@ -41,17 +53,26 @@ namespace HC_APTBS.Services.Impl
|
||||
if (pump.UnlockType == 0) return;
|
||||
|
||||
_log.Info(LogId, $"Starting immobilizer unlock (type {pump.UnlockType}) for {pump.Id}");
|
||||
|
||||
// ── Start persistent CAN senders FIRST ───────────────────────────────
|
||||
// These must be active before any unlock attempt and must continue
|
||||
// running after the unlock completes to prevent re-locking.
|
||||
StartSenders(pump.UnlockType);
|
||||
StatusChanged?.Invoke("Unlocking...");
|
||||
|
||||
// ── Phase 1: Continuous sends for ~10 minutes ─────────────────────────
|
||||
await RunPhase1Async(pump.UnlockType, ct);
|
||||
// ── 600 s CAN wait + parallel K-Line fast unlock attempt ─────────────
|
||||
// The fast unlock shortens the pump's internal 10 min timer via K-Line.
|
||||
// It can only be attempted once the K-Line session is Connected (the
|
||||
// read-all-info must finish first). If the fast unlock succeeds AND
|
||||
// the CAN TestUnlock parameter confirms it, we skip the remaining wait.
|
||||
await WaitWithFastUnlockAsync(pump, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// ── Phase 2: TestUnlock state machine ─────────────────────────────────
|
||||
// ── Phase 2: TestUnlock state machine ────────────────────────────────
|
||||
StatusChanged?.Invoke("Testing unlock...");
|
||||
RunTestUnlockSequence(pump.UnlockType);
|
||||
|
||||
// ── Verify unlock status ──────────────────────────────────────────────
|
||||
// ── Verify unlock status via CAN TestUnlock parameter ────────────────
|
||||
bool success = VerifyUnlock(pump);
|
||||
|
||||
_log.Info(LogId, $"Unlock complete — success={success}");
|
||||
@@ -59,11 +80,27 @@ namespace HC_APTBS.Services.Impl
|
||||
UnlockCompleted?.Invoke(success);
|
||||
}
|
||||
|
||||
// ── Phase 1 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task RunPhase1Async(int unlockType, CancellationToken ct)
|
||||
/// <inheritdoc/>
|
||||
public void StopSenders()
|
||||
{
|
||||
// Build message payloads based on unlock type.
|
||||
if (_senderCts == null) return;
|
||||
_log.Info(LogId, "Stopping persistent CAN unlock senders");
|
||||
_senderCts.Cancel();
|
||||
_senderCts.Dispose();
|
||||
_senderCts = null;
|
||||
}
|
||||
|
||||
// ── Persistent CAN senders ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Starts the two continuous CAN message senders. They run indefinitely
|
||||
/// until <see cref="StopSenders"/> is called (on pump deselection).
|
||||
/// </summary>
|
||||
private void StartSenders(int unlockType)
|
||||
{
|
||||
// Stop any leftover senders from a previous unlock.
|
||||
StopSenders();
|
||||
|
||||
byte[] msg1Data = new byte[8];
|
||||
uint msg1Id;
|
||||
byte[] msg2Data = new byte[8];
|
||||
@@ -88,66 +125,174 @@ namespace HC_APTBS.Services.Impl
|
||||
return;
|
||||
}
|
||||
|
||||
// Run two parallel senders for the full unlock duration.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(UnlockDurationMs);
|
||||
var linkedCt = cts.Token;
|
||||
_senderCts = new CancellationTokenSource();
|
||||
var senderCt = _senderCts.Token;
|
||||
|
||||
var sender1 = Task.Run(async () =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!linkedCt.IsCancellationRequested)
|
||||
while (!senderCt.IsCancellationRequested)
|
||||
{
|
||||
_can.SendRawMessage(msg1Id, msg1Data);
|
||||
await Task.Delay(500, linkedCt);
|
||||
await Task.Delay(500, senderCt);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}, linkedCt);
|
||||
}, senderCt);
|
||||
|
||||
var sender2 = Task.Run(async () =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!linkedCt.IsCancellationRequested)
|
||||
while (!senderCt.IsCancellationRequested)
|
||||
{
|
||||
_can.SendRawMessage(msg2Id, msg2Data);
|
||||
await Task.Delay(50, linkedCt);
|
||||
await Task.Delay(50, senderCt);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}, linkedCt);
|
||||
}, senderCt);
|
||||
|
||||
// Report progress periodically.
|
||||
var progressTask = Task.Run(async () =>
|
||||
{
|
||||
var start = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
while (!linkedCt.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(1000, linkedCt);
|
||||
var elapsed = DateTime.UtcNow - start;
|
||||
int pct = (int)(elapsed.TotalMilliseconds * 100 / UnlockDurationMs);
|
||||
string time = $"{(int)elapsed.TotalMinutes:D2}:{elapsed.Seconds:D2}";
|
||||
StatusChanged?.Invoke($"Unlocking... {Math.Min(pct, 100)}% ({time})");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}, linkedCt);
|
||||
_log.Info(LogId, $"Persistent CAN senders started (type {unlockType})");
|
||||
}
|
||||
|
||||
await Task.WhenAll(sender1, sender2, progressTask);
|
||||
// ── Wait with parallel fast-unlock ───────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Runs the 600 s progress wait. In parallel, monitors the K-Line session:
|
||||
/// once it becomes Connected, checks if the pump is still locked, sends the
|
||||
/// fast unlock command, and if the pump verifies unlocked, cancels the
|
||||
/// remaining wait time.
|
||||
/// </summary>
|
||||
private async Task WaitWithFastUnlockAsync(PumpDefinition pump, CancellationToken ct)
|
||||
{
|
||||
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
waitCts.CancelAfter(UnlockDurationMs);
|
||||
var waitCt = waitCts.Token;
|
||||
|
||||
// Progress reporting task.
|
||||
var progressTask = ReportProgressAsync(waitCt);
|
||||
|
||||
// Parallel fast-unlock task — awaits K-Line session, then attempts shortcut.
|
||||
var fastTask = TryFastUnlockWhenReadyAsync(pump, waitCts, ct);
|
||||
|
||||
// Wait for either: the full duration elapses, or the fast unlock succeeds
|
||||
// and cancels the wait CTS.
|
||||
await Task.WhenAll(progressTask, fastTask);
|
||||
|
||||
// If the outer ct was cancelled (user stop), propagate.
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
/// <summary>Reports progress every second until the wait token is cancelled.</summary>
|
||||
private async Task ReportProgressAsync(CancellationToken waitCt)
|
||||
{
|
||||
var start = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
while (!waitCt.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(1000, waitCt);
|
||||
var elapsed = DateTime.UtcNow - start;
|
||||
int pct = (int)(elapsed.TotalMilliseconds * 100 / UnlockDurationMs);
|
||||
string time = $"{(int)elapsed.TotalMinutes:D2}:{elapsed.Seconds:D2}";
|
||||
StatusChanged?.Invoke($"Unlocking... {Math.Min(pct, 100)}% ({time})");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the K-Line session to become Connected, then attempts the
|
||||
/// fast unlock. If the pump verifies unlocked afterward, cancels <paramref name="waitCts"/>
|
||||
/// to skip the remaining 600 s wait.
|
||||
/// </summary>
|
||||
private async Task TryFastUnlockWhenReadyAsync(
|
||||
PumpDefinition pump, CancellationTokenSource waitCts, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Wait for K-Line session to become Connected.
|
||||
if (_kwp.KLineState != KLineConnectionState.Connected)
|
||||
{
|
||||
_log.Info(LogId, "Waiting for K-Line session to connect...");
|
||||
var connectedTcs = new TaskCompletionSource<bool>();
|
||||
|
||||
void OnStateChanged(KLineConnectionState state)
|
||||
{
|
||||
if (state == KLineConnectionState.Connected)
|
||||
connectedTcs.TrySetResult(true);
|
||||
}
|
||||
|
||||
_kwp.KLineStateChanged += OnStateChanged;
|
||||
try
|
||||
{
|
||||
// Check again after subscribing (race guard).
|
||||
if (_kwp.KLineState == KLineConnectionState.Connected)
|
||||
connectedTcs.TrySetResult(true);
|
||||
|
||||
// Wait for connection or cancellation (user cancel or 600 s elapsed).
|
||||
using var reg = ct.Register(() => connectedTcs.TrySetCanceled());
|
||||
using var waitReg = waitCts.Token.Register(() => connectedTcs.TrySetCanceled());
|
||||
await connectedTcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_kwp.KLineStateChanged -= OnStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
// K-Line is now connected. Check if the pump is still locked.
|
||||
if (VerifyUnlock(pump))
|
||||
{
|
||||
_log.Info(LogId, "Pump already unlocked — skipping wait");
|
||||
waitCts.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pump is locked — attempt the fast K-Line unlock (RAM timer shortcut).
|
||||
_log.Info(LogId, "Attempting K-Line fast unlock (timer shortcut)...");
|
||||
StatusChanged?.Invoke("Fast unlock attempt...");
|
||||
|
||||
bool ack = await _kwp.TryFastUnlockAsync();
|
||||
if (!ack)
|
||||
{
|
||||
_log.Info(LogId, "Fast unlock NAK or failed — continuing normal wait");
|
||||
StatusChanged?.Invoke("Unlocking...");
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Info(LogId, "Fast unlock ACK — waiting briefly for pump to process");
|
||||
|
||||
// Give the pump a moment to process the timer shortcut, then verify.
|
||||
await Task.Delay(2000, ct);
|
||||
|
||||
if (VerifyUnlock(pump))
|
||||
{
|
||||
_log.Info(LogId, "Fast unlock verified — skipping remaining wait");
|
||||
waitCts.Cancel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Info(LogId, "Fast unlock ACK'd but pump still locked — continuing normal wait");
|
||||
StatusChanged?.Invoke("Unlocking...");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Wait elapsed or user cancelled — fast unlock window closed, that's fine.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(LogId, $"Fast unlock attempt error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: TestUnlock state machine ────────────────────────────────────
|
||||
|
||||
private void RunTestUnlockSequence(int unlockType)
|
||||
{
|
||||
// The state machine cycles through 4 command bytes, twice.
|
||||
byte[][] type1Cmds =
|
||||
{
|
||||
new byte[] { 0xB2, 0, 0, 0, 0, 0, 0, 0 },
|
||||
@@ -188,8 +333,9 @@ namespace HC_APTBS.Services.Impl
|
||||
switch (pump.UnlockType)
|
||||
{
|
||||
case 1:
|
||||
// Type 1: unlocked when TestUnlock value is non-zero.
|
||||
return unlockParam.Value != 0;
|
||||
// Type 1: unlocked when TestUnlock value is zero.
|
||||
// Old code: Lock = valor != 0 (non-zero = locked).
|
||||
return unlockParam.Value == 0;
|
||||
case 2:
|
||||
// Type 2: unlocked when TestUnlock value equals 0xE4 (228).
|
||||
return (int)unlockParam.Value == 0xE4;
|
||||
|
||||
Reference in New Issue
Block a user