Bundles several feature streams that have been iterating on the working tree: - Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the identification card, manual KWP write + transaction log, ROM/EEPROM dump card with progress banner and completion message, persisted custom-commands library, persisted EEPROM passwords library. New service primitives: IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync. Persistence mirrors the Clients XML pattern in two new files (custom_commands.xml, eeprom_passwords.xml). - Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and progress dialog VM, gated on dashboard alarms. - BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at 0x0106 via IKwpService.ReadBipStatusAsync; status definitions in BipStatusDefinition. - Tests page redesign: TestSectionCard + PhaseTileView replacing the old TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/ TestSectionView controls and their VMs. - Pump command sliders: Fluent thick-track style with overhang thumb, click-anywhere-and-drag, mouse-wheel adjustment. - Window startup: app.manifest declares PerMonitorV2 DPI awareness, MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and maximizes there (after the hook is in place) so the app fits the work area exactly on any display configuration. - Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and dump-functions reference docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
494 lines
21 KiB
C#
494 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 event Action? PumpRelocked;
|
|
|
|
/// <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();
|
|
else PumpRelocked?.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(pump.UnlockType).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;
|
|
}
|
|
}
|
|
}
|
|
}
|