diff --git a/Services/IUnlockService.cs b/Services/IUnlockService.cs
index 4538f84..90f94ff 100644
--- a/Services/IUnlockService.cs
+++ b/Services/IUnlockService.cs
@@ -17,6 +17,37 @@ namespace HC_APTBS.Services
/// Raised when the unlock sequence completes. Argument is true if successful.
event Action? UnlockCompleted;
+ ///
+ /// Raised by the background observer on each lock→unlock transition. Unlike
+ /// , this fires as soon as the CAN TestUnlock
+ /// parameter confirms unlocked — regardless of whether the transition was
+ /// caused by this service, an external manual unlock, or a fast-unlock
+ /// completing early. Subscribers must marshal to the UI thread themselves.
+ ///
+ event Action? PumpUnlocked;
+
+ ///
+ /// Latched state from the background observer. True when the observer has
+ /// verified the pump is unlocked; false when the observer is not running
+ /// or the pump is currently locked. Use alongside
+ /// to race-guard "was the event already fired?".
+ ///
+ bool IsPumpUnlocked { get; }
+
+ ///
+ /// Starts a 1-second polling observer that watches the CAN TestUnlock
+ /// parameter and raises on each lock→unlock
+ /// transition. Idempotent: stops any prior observer before starting the
+ /// new one. No-op when is 0.
+ ///
+ void StartObserver(PumpDefinition pump);
+
+ ///
+ /// Stops the polling observer. Safe to call when no observer is active.
+ /// Also resets to false.
+ ///
+ void StopObserver();
+
///
/// Runs the immobilizer unlock sequence for the given pump.
/// Returns immediately if is 0 (no unlock needed).
diff --git a/Services/Impl/UnlockService.cs b/Services/Impl/UnlockService.cs
index d82c9b4..9589e82 100644
--- a/Services/Impl/UnlockService.cs
+++ b/Services/Impl/UnlockService.cs
@@ -33,12 +33,27 @@ namespace HC_APTBS.Services.Impl
/// 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)
{
@@ -65,12 +80,15 @@ namespace HC_APTBS.Services.Impl
// 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);
+ // 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...");
- RunTestUnlockSequence(pump.UnlockType);
+ await RunTestUnlockSequenceAsync(pump.UnlockType, ct).ConfigureAwait(false);
// ── Verify unlock status via CAN TestUnlock parameter ────────────────
bool success = VerifyUnlock(pump);
@@ -90,6 +108,78 @@ namespace HC_APTBS.Services.Impl
_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 ───────────────────────────────────────────────
///
@@ -171,15 +261,50 @@ namespace HC_APTBS.Services.Impl
waitCts.CancelAfter(UnlockDurationMs);
var waitCt = waitCts.Token;
- // Progress reporting task.
- var progressTask = ReportProgressAsync(waitCt);
+ // 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;
+ }
- // Parallel fast-unlock task — awaits K-Line session, then attempts shortcut.
- var fastTask = TryFastUnlockWhenReadyAsync(pump, waitCts, ct);
+ // 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;
- // Wait for either: the full duration elapses, or the fast unlock succeeds
- // and cancels the wait CTS.
- await Task.WhenAll(progressTask, fastTask);
+ 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();
@@ -193,7 +318,7 @@ namespace HC_APTBS.Services.Impl
{
while (!waitCt.IsCancellationRequested)
{
- await Task.Delay(1000, waitCt);
+ 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}";
@@ -235,7 +360,7 @@ namespace HC_APTBS.Services.Impl
// 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;
+ await connectedTcs.Task.ConfigureAwait(false);
}
finally
{
@@ -255,7 +380,7 @@ namespace HC_APTBS.Services.Impl
_log.Info(LogId, "Attempting K-Line fast unlock (timer shortcut)...");
StatusChanged?.Invoke("Fast unlock attempt...");
- bool ack = await _kwp.TryFastUnlockAsync();
+ bool ack = await _kwp.TryFastUnlockAsync().ConfigureAwait(false);
if (!ack)
{
_log.Info(LogId, "Fast unlock NAK or failed — continuing normal wait");
@@ -266,7 +391,7 @@ namespace HC_APTBS.Services.Impl
_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);
+ await Task.Delay(2000, ct).ConfigureAwait(false);
if (VerifyUnlock(pump))
{
@@ -291,7 +416,13 @@ namespace HC_APTBS.Services.Impl
// ── Phase 2: TestUnlock state machine ────────────────────────────────────
- private void RunTestUnlockSequence(int unlockType)
+ // 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 =
{
@@ -316,7 +447,7 @@ namespace HC_APTBS.Services.Impl
for (int step = 0; step < cmds.Length; step++)
{
_can.SendRawMessage(0x700, cmds[step]);
- Thread.Sleep(500);
+ await Task.Delay(500, ct).ConfigureAwait(false);
}
}