Skip to content

fix(vehicle): prevent BLE unconfirmed command failover - #66

Merged
Bre77 merged 6 commits into
mainfrom
fm/tfa-ble-unconfirmed-ack-p4
Jul 12, 2026
Merged

fix(vehicle): prevent BLE unconfirmed command failover#66
Bre77 merged 6 commits into
mainfrom
fm/tfa-ble-unconfirmed-ack-p4

Conversation

@Bre77

@Bre77 Bre77 commented Jul 12, 2026

Copy link
Copy Markdown
Member

Intent

Captain-decided library behavior change: a BLE command that times out waiting for the ACK must be treated as UNCONFIRMED SUCCESS, not a failure. Live evidence on record: door_lock and door_unlock both raised BluetoothTimeout yet both physically executed (see AGENTS.md 'BLE mutating-command timeout is inconclusive' gotcha and the ble-timeout-baserate memory: ~161 measured timeouts, ~100% executed, ack lost not write lost, 100% VCSEC/0% INFO). The consuming Router (VehicleRouter, BLE-primary + Teslemetry cloud fallback, in this same repo's router/base.py) currently treats any exception including BluetoothTimeout as a generic failure and fails over to the next backend - risking double-executing a command that already ran.

Fix, scoped to library code only (no live car interaction):

  1. Added BluetoothUnconfirmedCommand(BluetoothTimeout) in exceptions.py. Deliberately a BluetoothTimeout subclass so every existing 'except BluetoothTimeout' call site (the verify_commands resolution path, pair()'s fast-path fallback to polling) keeps working unchanged, while remaining a distinct type callers can catch separately.
  2. VehicleBluetooth._sendVehicleSecurity/_sendInfotainment (bluetooth.py) - the mutating-command entry points, already the seam used by the existing verify_commands feature - now unconditionally (not gated by verify_commands) wrap a caught BluetoothTimeout into BluetoothUnconfirmedCommand before it can reach a caller. This only fires for a timeout AFTER the write succeeded (write/connect failures raise the pre-existing, untouched BluetoothTransportError and still fail over normally); reads (_getVehicleSecurity/_getInfotainment, e.g. charge_state()) are untouched and still raise plain BluetoothTimeout since a read has no side effect to be 'unconfirmed' about.
  3. Router._dispatch (router/base.py) special-cases BluetoothUnconfirmedCommand: it does NOT trigger per-command failover (unlike every other exception type) and instead re-raises immediately to the caller, on any backend in the chain, so VehicleRouter's BLE-primary/cloud-fallback dispatch can no longer double-execute a mutating command whose ack was merely lost.

Deliberately out of scope / unchanged: the WAIT/fault signed-command retry in commands.py, the cloud (Teslemetry/Tessie/Fleet REST) path, and verify_commands' own resolution logic (still opt-in, still only reads back state when enabled - it now also raises the new type on an unresolved timeout instead of plain BluetoothTimeout, but its behavior and default-off posture are otherwise unchanged).

Tests added (tests/test_ble_unconfirmed_command.py, new; tests/test_router.py, extended): mutating-command timeout raises BluetoothUnconfirmedCommand (VCSEC and infotainment paths), it's still catchable via 'except BluetoothTimeout', the original timeout is chained as cause, verify_commands' unresolved path raises the new type, a read timeout still raises plain BluetoothTimeout (not the subclass), a pre-write BluetoothTransportError is unaffected and not an instance of either timeout type, and Router-level tests proving BluetoothUnconfirmedCommand does not fail over (2-way and N-way chains) while plain BluetoothTimeout still does (pre-existing test, unchanged).

Note: a quantified timeout-rate study is referenced in project memory (ble-timeout-baserate, ~161 measured timeouts) but that measurement work itself is a separate, already-completed effort - not part of this change.

What Changed

  • Added BluetoothUnconfirmedCommand for BLE mutating-command ACK timeouts, preserving BluetoothTimeout compatibility while distinguishing commands that may already have executed.
  • Updated BLE send paths and router dispatch so unconfirmed mutating BLE commands are re-raised without cloud/backend failover, while handshake, read-like, and pre-write transport failures keep their existing behavior.
  • Documented the new timeout semantics and added focused tests for BLE unconfirmed command handling, verify-on-timeout behavior, and router failover rules.

Risk Assessment

✅ Low: The change is well-bounded to BLE post-write timeout classification and router failover, with the prior handshake and non-mutating infotainment edge cases now covered in code and tests.

Testing

Inspected the target diff, ran the focused BLE/router tests and the full test suite successfully, then recorded a mocked library-level transcript showing door_lock() raises BluetoothUnconfirmedCommand, remains catchable as BluetoothTimeout, preserves the original timeout cause, prevents router fallback, and still allows fallback for plain BluetoothTimeout.

Evidence: BLE unconfirmed/router behavior transcript
BLE mutating command: door_lock() raised BluetoothUnconfirmedCommand
  still catchable as BluetoothTimeout: True
  original BluetoothTimeout chained as cause: True
Router with unconfirmed primary: propagated BluetoothUnconfirmedCommand
  primary calls: 1
  fallback calls: 0
Router with plain BluetoothTimeout primary: fell back to cloud
  returned: {'result': True, 'transport': 'cloud'}
  primary calls: 1
  fallback calls: 1

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed (3) ✅
  • 🚨 tesla_fleet_api/tesla/vehicle/bluetooth.py:768 - _sendVehicleSecurity and _sendInfotainment now wrap every BluetoothTimeout from super()._send* as BluetoothUnconfirmedCommand, but Commands._command can raise that timeout before the mutating command is ever written: if the signed-command session is not ready, _command first calls _handshake(domain). A timeout waiting for the handshake reply would now be reported as an unconfirmed executed command and would also stop Router fallback, even though no mutation was sent. The conversion needs to happen closer to the actual post-write wait, or otherwise distinguish handshake/read timeouts from the command send timeout.

🔧 Fix: Preserve handshake timeout classification
1 warning still open:

  • ⚠️ tesla_fleet_api/tesla/vehicle/bluetooth.py:759 - _ensure_handshake calls _handshake() but does not verify that it actually made the session ready. If _send() returns a non-session_info acknowledgement without raising, _handshake() returns False, then super()._sendVehicleSecurity()/_sendInfotainment() still enters Commands._command() with the session unready and performs its own lazy handshake inside the try that wraps BluetoothTimeout as BluetoothUnconfirmedCommand. That leaves a pre-command handshake timeout misclassified in the edge case this follow-up is trying to close. Make _ensure_handshake raise or otherwise keep the handshake outside the wrapping block until self._sessions[domain].ready is true.

🔧 Fix: Preserve pre-command handshake timeout classification
1 warning still open:

  • ⚠️ tesla_fleet_api/tesla/vehicle/bluetooth.py:807 - _sendInfotainment is not exclusively mutating: ping(), nearby_charging_sites(), and get_charge_on_solar() also route through it. Wrapping every timeout from this method as BluetoothUnconfirmedCommand makes Router suppress safe failover for those no-op/read-style calls even though there is no double-execution risk. Limit the wrap to known mutating infotainment oneof fields, or route non-mutating infotainment requests through a path that keeps plain BluetoothTimeout.

🔧 Fix: Preserve read-like infotainment timeout failover
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • git status --short --branch
  • git diff --stat 07548921affb0ab74b33ddf54d2c07ad6fef88a9..940207c0d4a20ea4204519fb9d1a08f6ddedbdd1
  • git diff --name-only 07548921affb0ab74b33ddf54d2c07ad6fef88a9..940207c0d4a20ea4204519fb9d1a08f6ddedbdd1
  • uv run pytest tests/test_ble_unconfirmed_command.py tests/test_router.py
  • uv run pytest tests
  • uv run python - <<'PY' > /tmp/no-mistakes-evidence/01KX9XPJQ9BMYFYAAFV9MR6DQB/ble_unconfirmed_router_transcript.txt ... PY
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

firstmate crewmate added 6 commits July 12, 2026 09:58
Live evidence: door_lock and door_unlock both raised BluetoothTimeout yet
both physically executed - the write succeeds and only the ack is lost.
A BLE-primary/cloud-fallback Router seeing that as a plain failure replays
the command on the next backend, risking double execution.

Add BluetoothUnconfirmedCommand (a BluetoothTimeout subclass) raised only
for a lost ack after a mutating command (_sendVehicleSecurity/_sendInfotainment)
was already written to the vehicle; reads and pre-write transport failures
are unaffected. Router._dispatch no longer fails over on this exception -
it propagates directly so callers can verify rather than blind-retry.
@Bre77
Bre77 merged commit 1d12b88 into main Jul 12, 2026
5 checks passed
Bre77 added a commit that referenced this pull request Jul 12, 2026
* feat(bluetooth): add optimistic and raise_unconfirmed BLE command knobs

Adds step 1 of the BLE command-confirmation ladder (write -> ack wait ->
verify_commands -> unconfirmed outcome), building on PR #66's
BluetoothUnconfirmedCommand:

- optimistic (default False): a mutating command returns success as soon as
  its GATT write is confirmed, skipping the ack wait and verify_commands
  entirely. Only a write/transport failure still raises.
- raise_unconfirmed (default True, current behavior): when False, an
  exhausted confirmation ladder resolves as a best-effort success instead of
  raising BluetoothUnconfirmedCommand. A car-side rejection, a
  verify_commands state mismatch, and write failures are unaffected and
  always raise.

Both are BLE-only constructor/factory knobs threaded through
Vehicles/VehiclesBluetooth.create*, matching verify_commands. Router needs no
changes: optimistic's only exception is a generic BluetoothTransportError
that already fails over correctly, and raise_unconfirmed=False's best-effort
success is just a normal return value.

Broadcast confirmation (the next rung of the ladder) is a separate follow-up.

* no-mistakes(document): Sync BLE confirmation docs

---------

Co-authored-by: firstmate crewmate <crewmate@firstmate.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant