Phase 2 TestUnlock handshake was synchronous (Thread.Sleep x 8 = 4 s) and the continuation after Phase 1 marshalled back to the WPF dispatcher via the captured SynchronizationContext, so the eight 500 ms sleeps froze the UI right before unlock completed. - UnlockService.RunTestUnlockSequence -> async RunTestUnlockSequenceAsync with Task.Delay(500, ct) and ConfigureAwait(false) - Add ConfigureAwait(false) on every internal await in UnlockService so continuations no longer hop to the UI thread (Task.WhenAll, Task.Delay, connectedTcs, TryFastUnlockAsync, fast-unlock settle delay) - CancellationToken now propagates through Phase 2, so the snackbar Cancel button can interrupt the handshake within 500 ms instead of waiting out all eight Thread.Sleeps Includes the companion observer in IUnlockService / UnlockService (PumpUnlocked event, IsPumpUnlocked, StartObserver/StopObserver) that the Phase 1 wait now races against — lets any source of unlock (fast unlock, external manual, CAN flood finally working) short-circuit the 600 s timer as soon as the CAN TestUnlock parameter confirms it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
479 lines
20 KiB
C#
479 lines
20 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;
|
|
|
|
/// <summary>
|
|
/// Observer subscription state. The observer is event-driven: it subscribes to
|
|
/// the pump's TestUnlock <see cref="CanBusParameter.ValueChanged"/> and raises
|
|
/// <see cref="PumpUnlocked"/> on every LOCKED→UNLOCKED transition. No polling.
|
|
/// </summary>
|
|
private PumpDefinition? _observerPump;
|
|
private CanBusParameter? _observerParam;
|
|
private volatile bool _isPumpUnlocked;
|
|
|
|
/// <inheritdoc/>
|
|
public event Action<string>? StatusChanged;
|
|
|
|
/// <inheritdoc/>
|
|
public event Action<bool>? UnlockCompleted;
|
|
|
|
/// <inheritdoc/>
|
|
public event Action? PumpUnlocked;
|
|
|
|
/// <inheritdoc/>
|
|
public bool IsPumpUnlocked => _isPumpUnlocked;
|
|
|
|
/// <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.
|
|
// ConfigureAwait(false) keeps the continuation off the UI thread — the
|
|
// caller (MainViewModel) invokes this fire-and-forget from the dispatcher,
|
|
// and Phase 2 below must not block it.
|
|
await WaitWithFastUnlockAsync(pump, ct).ConfigureAwait(false);
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
// ── Phase 2: TestUnlock state machine ────────────────────────────────
|
|
StatusChanged?.Invoke("Testing unlock...");
|
|
await RunTestUnlockSequenceAsync(pump.UnlockType, ct).ConfigureAwait(false);
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void StartObserver(PumpDefinition pump)
|
|
{
|
|
// Idempotent — tear down any prior observer first.
|
|
StopObserver();
|
|
|
|
if (pump.UnlockType == 0) return;
|
|
if (!pump.ParametersByName.TryGetValue(PumpParameterNames.TestUnlock, out var param))
|
|
{
|
|
_log.Warning(LogId,
|
|
$"StartObserver: pump {pump.Id} has no '{PumpParameterNames.TestUnlock}' CAN param — observer not started.");
|
|
return;
|
|
}
|
|
|
|
// Publish the target fields BEFORE subscribing so that if a CAN frame fires
|
|
// the handler on the CAN read thread before this method returns, the handler
|
|
// can resolve _observerPump correctly.
|
|
_observerPump = pump;
|
|
_observerParam = param;
|
|
|
|
// Subscribe BEFORE seeding. Once TestUnlock settles at its unlocked value the
|
|
// decoder stops raising ValueChanged (it only fires on deltas), so missing the
|
|
// single LOCKED→UNLOCKED transition between seed and subscribe would be
|
|
// permanent. Subscribe-then-seed closes that window; a possible redundant fire
|
|
// from a concurrent handler is suppressed by the !wasUnlocked guard below.
|
|
param.ValueChanged += OnTestUnlockChanged;
|
|
|
|
// Seed the latched state from the current parameter value.
|
|
bool wasUnlocked = _isPumpUnlocked;
|
|
_isPumpUnlocked = VerifyUnlock(pump);
|
|
|
|
_log.Info(LogId, $"Unlock observer started for pump {pump.Id} (initial state: {(_isPumpUnlocked ? "UNLOCKED" : "LOCKED")})");
|
|
|
|
// Fire synchronously only if the seed itself revealed UNLOCKED — if the
|
|
// handler already raised PumpUnlocked between subscribe and seed, wasUnlocked
|
|
// is true and we skip a duplicate fire.
|
|
if (_isPumpUnlocked && !wasUnlocked)
|
|
PumpUnlocked?.Invoke();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void StopObserver()
|
|
{
|
|
if (_observerParam == null) return;
|
|
_log.Info(LogId, "Stopping unlock observer");
|
|
_observerParam.ValueChanged -= OnTestUnlockChanged;
|
|
_observerParam = null;
|
|
_observerPump = null;
|
|
_isPumpUnlocked = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles <see cref="CanBusParameter.ValueChanged"/> on the CAN read thread.
|
|
/// Detects LOCKED↔UNLOCKED transitions and raises <see cref="PumpUnlocked"/> on
|
|
/// the LOCKED→UNLOCKED edge. Runs on the CAN thread — subscribers that touch
|
|
/// WPF state must marshal themselves.
|
|
/// </summary>
|
|
private void OnTestUnlockChanged(CanBusParameter _)
|
|
{
|
|
// Capture into a local so a concurrent StopObserver clearing the field
|
|
// mid-handler does not null-deref us.
|
|
var pump = _observerPump;
|
|
if (pump == null) return;
|
|
|
|
bool unlocked = VerifyUnlock(pump);
|
|
if (unlocked == _isPumpUnlocked) return;
|
|
|
|
_isPumpUnlocked = unlocked;
|
|
_log.Info(LogId, $"Observer: pump {pump.Id} transitioned {(unlocked ? "LOCKED → UNLOCKED" : "UNLOCKED → LOCKED")}");
|
|
if (unlocked) PumpUnlocked?.Invoke();
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
// Short-circuit immediately if the background observer has already
|
|
// latched an unlocked state (e.g. the pump was unlocked from a prior
|
|
// session, or an external process unlocked it before UnlockAsync ran).
|
|
if (_isPumpUnlocked)
|
|
{
|
|
_log.Info(LogId, "Observer already reports pump unlocked — skipping Phase 1 wait");
|
|
return;
|
|
}
|
|
|
|
// Subscribe to the observer's PumpUnlocked event so ANY source of
|
|
// unlock (fast unlock, external manual unlock, the CAN flood itself
|
|
// eventually working) cancels the 600 s wait as soon as the CAN
|
|
// TestUnlock parameter confirms it — not just the fast-unlock path.
|
|
void OnObserverUnlocked()
|
|
{
|
|
_log.Info(LogId, "Observer signalled unlock — cancelling Phase 1 wait");
|
|
try { waitCts.Cancel(); } catch (ObjectDisposedException) { }
|
|
}
|
|
PumpUnlocked += OnObserverUnlocked;
|
|
|
|
try
|
|
{
|
|
// Race guard: the event may have fired between the IsPumpUnlocked
|
|
// check above and the subscription. Re-check now that we're wired up.
|
|
if (_isPumpUnlocked)
|
|
{
|
|
_log.Info(LogId, "Observer reports pump unlocked (post-subscribe) — skipping Phase 1 wait");
|
|
return;
|
|
}
|
|
|
|
// 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, the fast unlock succeeds
|
|
// and cancels the wait CTS, or the observer fires PumpUnlocked.
|
|
await Task.WhenAll(progressTask, fastTask).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
PumpUnlocked -= OnObserverUnlocked;
|
|
}
|
|
|
|
// 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).ConfigureAwait(false);
|
|
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.ConfigureAwait(false);
|
|
}
|
|
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().ConfigureAwait(false);
|
|
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).ConfigureAwait(false);
|
|
|
|
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 ────────────────────────────────────
|
|
|
|
// Async so the 500 ms pump-protocol pacing between handshake commands
|
|
// uses Task.Delay instead of Thread.Sleep — otherwise the continuation
|
|
// after WaitWithFastUnlockAsync (which can land on the UI thread via the
|
|
// captured SynchronizationContext) would block the WPF dispatcher for
|
|
// ~4 s while the eight Thread.Sleep(500) calls complete. Propagating ct
|
|
// also makes user-cancel responsive mid-handshake.
|
|
private async Task RunTestUnlockSequenceAsync(int unlockType, CancellationToken ct)
|
|
{
|
|
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]);
|
|
await Task.Delay(500, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|