Files
HC_APTBS/Services/Impl/UnlockService.cs
LucianoDev da0581967b fix: gate Ford VP44 unlock on CAN liveness to prevent false-unlocked reads
Before this fix, StartUnlockIfRequired was called immediately after
registering the pump's CAN parameters, before any frames had been
decoded. The TestUnlock parameter's zero-initialised Value was
interpreted as "unlocked" for Type 1 pumps, causing Phase 1 to be
skipped and UnlockCompleted(true) to fire falsely.

Changes:
- ICanService: add IsPumpAlive property (volatile-backed in PcanAdapter)
- PcanAdapter: implement IsPumpAlive; mark _pumpAlive/_benchAlive volatile
  for safe cross-thread reads
- MainViewModel: replace direct StartUnlockIfRequired call with a
  fire-and-forget WaitForPumpCanThenUnlockAsync that waits for
  PumpLivenessChanged(true) + 250 ms grace, then invokes unlock on the
  UI thread; cancellation on pump change or CAN disconnect via
  _pumpLivenessCts
- UnlockService.UnlockAsync: skip Phase 2 state-machine when observer
  seed already reports unlocked (senders still run to prevent re-lock)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:52:16 +02:00

490 lines
21 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 ────────────────────────────────
// Skip when the observer already latched an unlocked state — the
// four 0x700 commands are a no-op in that case and just keep the
// dispatcher/CAN bus busy for ~4 s. Senders remain running so the
// Ford ECU doesn't re-lock.
if (_isPumpUnlocked)
{
_log.Info(LogId, "Pump already unlocked — skipping Phase 2 state machine");
}
else
{
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;
}
}
}
}