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>
348 lines
14 KiB
C#
348 lines
14 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using HC_APTBS.Models;
|
|
|
|
namespace HC_APTBS.Services.Impl
|
|
{
|
|
/// <summary>
|
|
/// Implements the immobilizer unlock sequence for Ford VP44 pump ECUs.
|
|
/// <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>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 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 and K-Line buses.</summary>
|
|
public UnlockService(ICanService canService, IKwpService kwpService, IAppLogger logger)
|
|
{
|
|
_can = canService;
|
|
_kwp = kwpService;
|
|
_log = logger;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task UnlockAsync(PumpDefinition pump, CancellationToken ct)
|
|
{
|
|
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...");
|
|
|
|
// ── 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 ────────────────────────────────
|
|
StatusChanged?.Invoke("Testing unlock...");
|
|
RunTestUnlockSequence(pump.UnlockType);
|
|
|
|
// ── Verify unlock status via CAN TestUnlock parameter ────────────────
|
|
bool success = VerifyUnlock(pump);
|
|
|
|
_log.Info(LogId, $"Unlock complete — success={success}");
|
|
StatusChanged?.Invoke(success ? "Unlocked" : "Unlock failed");
|
|
UnlockCompleted?.Invoke(success);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void StopSenders()
|
|
{
|
|
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];
|
|
uint msg2Id;
|
|
|
|
switch (unlockType)
|
|
{
|
|
case 1:
|
|
msg1Id = 0x700;
|
|
msg1Data[0] = 0xB2;
|
|
msg2Id = 0x300;
|
|
msg2Data[0] = 0x01; msg2Data[1] = 0x48;
|
|
msg2Data[2] = 0x50; msg2Data[3] = 0xC3;
|
|
break;
|
|
case 2:
|
|
msg1Id = 0x700;
|
|
msg1Data[3] = 0xB2;
|
|
msg2Id = 0x500;
|
|
msg2Data[4] = 0x78;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
_senderCts = new CancellationTokenSource();
|
|
var senderCt = _senderCts.Token;
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
while (!senderCt.IsCancellationRequested)
|
|
{
|
|
_can.SendRawMessage(msg1Id, msg1Data);
|
|
await Task.Delay(500, senderCt);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
}, senderCt);
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
while (!senderCt.IsCancellationRequested)
|
|
{
|
|
_can.SendRawMessage(msg2Id, msg2Data);
|
|
await Task.Delay(50, senderCt);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
}, senderCt);
|
|
|
|
_log.Info(LogId, $"Persistent CAN senders started (type {unlockType})");
|
|
}
|
|
|
|
// ── 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)
|
|
{
|
|
byte[][] type1Cmds =
|
|
{
|
|
new byte[] { 0xB2, 0, 0, 0, 0, 0, 0, 0 },
|
|
new byte[] { 0xB6, 0, 0, 0, 0, 0, 0, 0 },
|
|
new byte[] { 0x23, 0, 0, 0, 0, 0, 0, 0 },
|
|
new byte[] { 0x24, 0, 0, 0, 0, 0, 0, 0 }
|
|
};
|
|
|
|
byte[][] type2Cmds =
|
|
{
|
|
new byte[] { 0, 0, 0, 0xB2, 0, 0, 0, 0 },
|
|
new byte[] { 0, 0, 0, 0x24, 0, 0, 0, 0 },
|
|
new byte[] { 0, 0, 0, 0x24, 0, 0, 0, 0 },
|
|
new byte[] { 0, 0, 0, 0x24, 0, 0, 0, 0 }
|
|
};
|
|
|
|
byte[][] cmds = unlockType == 1 ? type1Cmds : type2Cmds;
|
|
|
|
for (int loop = 0; loop < 2; loop++)
|
|
{
|
|
for (int step = 0; step < cmds.Length; step++)
|
|
{
|
|
_can.SendRawMessage(0x700, cmds[step]);
|
|
Thread.Sleep(500);
|
|
}
|
|
}
|
|
|
|
StatusChanged?.Invoke("Sending...");
|
|
}
|
|
|
|
// ── Verification ─────────────────────────────────────────────────────────
|
|
|
|
private bool VerifyUnlock(PumpDefinition pump)
|
|
{
|
|
if (!pump.ParametersByName.TryGetValue(PumpParameterNames.TestUnlock, out var unlockParam))
|
|
return false;
|
|
|
|
switch (pump.UnlockType)
|
|
{
|
|
case 1:
|
|
// 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;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|