using System; using System.Threading; using System.Threading.Tasks; using HC_APTBS.Models; namespace HC_APTBS.Services.Impl { /// /// Implements the immobilizer unlock sequence for Ford VP44 pump ECUs. /// The CAN flood messages must start before unlocking and continue running /// after unlock completes — stopping them causes the pump to re-lock. Call /// only when the pump is deselected. /// /// Start persistent CAN senders (run until explicitly stopped) /// Begin the 600 s CAN wait with progress reporting /// 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 /// TestUnlock state-machine handshake on 0x700 /// Verify via CAN TestUnlock parameter /// /// public sealed class UnlockService : IUnlockService { private readonly ICanService _can; private readonly IKwpService _kwp; private readonly IAppLogger _log; private const string LogId = "UnlockService"; /// Total duration of the Phase 1 wait (milliseconds). private const int UnlockDurationMs = 600_500; /// CTS for the persistent CAN senders — lives beyond . private CancellationTokenSource? _senderCts; /// /// Observer subscription state. The observer is event-driven: it subscribes to /// the pump's TestUnlock and raises /// on every LOCKED→UNLOCKED transition. No polling. /// private PumpDefinition? _observerPump; private CanBusParameter? _observerParam; private volatile bool _isPumpUnlocked; /// public event Action? StatusChanged; /// public event Action? UnlockCompleted; /// public event Action? PumpUnlocked; /// public bool IsPumpUnlocked => _isPumpUnlocked; /// Creates the unlock service wired to the CAN and K-Line buses. public UnlockService(ICanService canService, IKwpService kwpService, IAppLogger logger) { _can = canService; _kwp = kwpService; _log = logger; } /// 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); } /// public void StopSenders() { if (_senderCts == null) return; _log.Info(LogId, "Stopping persistent CAN unlock senders"); _senderCts.Cancel(); _senderCts.Dispose(); _senderCts = null; } /// 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(); } /// public void StopObserver() { if (_observerParam == null) return; _log.Info(LogId, "Stopping unlock observer"); _observerParam.ValueChanged -= OnTestUnlockChanged; _observerParam = null; _observerPump = null; _isPumpUnlocked = false; } /// /// Handles on the CAN read thread. /// Detects LOCKED↔UNLOCKED transitions and raises on /// the LOCKED→UNLOCKED edge. Runs on the CAN thread — subscribers that touch /// WPF state must marshal themselves. /// 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 ─────────────────────────────────────────────── /// /// Starts the two continuous CAN message senders. They run indefinitely /// until is called (on pump deselection). /// 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 ─────────────────────────────────────── /// /// 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. /// 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(); } /// Reports progress every second until the wait token is cancelled. 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) { } } /// /// Waits for the K-Line session to become Connected, then attempts the /// fast unlock. If the pump verifies unlocked afterward, cancels /// to skip the remaining 600 s wait. /// 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(); 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; } } } }