fix(vehicle): prevent BLE unconfirmed command failover - #66
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
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
BluetoothUnconfirmedCommandfor BLE mutating-command ACK timeouts, preservingBluetoothTimeoutcompatibility while distinguishing commands that may already have executed.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()raisesBluetoothUnconfirmedCommand, remains catchable asBluetoothTimeout, preserves the original timeout cause, prevents router fallback, and still allows fallback for plainBluetoothTimeout.Evidence: BLE unconfirmed/router behavior transcript
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-_sendVehicleSecurityand_sendInfotainmentnow wrap everyBluetoothTimeoutfromsuper()._send*asBluetoothUnconfirmedCommand, butCommands._commandcan raise that timeout before the mutating command is ever written: if the signed-command session is not ready,_commandfirst calls_handshake(domain). A timeout waiting for the handshake reply would now be reported as an unconfirmed executed command and would also stopRouterfallback, 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_handshakecalls_handshake()but does not verify that it actually made the session ready. If_send()returns a non-session_infoacknowledgement without raising,_handshake()returnsFalse, thensuper()._sendVehicleSecurity()/_sendInfotainment()still entersCommands._command()with the session unready and performs its own lazy handshake inside thetrythat wrapsBluetoothTimeoutasBluetoothUnconfirmedCommand. That leaves a pre-command handshake timeout misclassified in the edge case this follow-up is trying to close. Make_ensure_handshakeraise or otherwise keep the handshake outside the wrapping block untilself._sessions[domain].readyis true.🔧 Fix: Preserve pre-command handshake timeout classification
1 warning still open:
tesla_fleet_api/tesla/vehicle/bluetooth.py:807-_sendInfotainmentis not exclusively mutating:ping(),nearby_charging_sites(), andget_charge_on_solar()also route through it. Wrapping every timeout from this method asBluetoothUnconfirmedCommandmakesRoutersuppress 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 plainBluetoothTimeout.🔧 Fix: Preserve read-like infotainment timeout failover
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
git status --short --branchgit diff --stat 07548921affb0ab74b33ddf54d2c07ad6fef88a9..940207c0d4a20ea4204519fb9d1a08f6ddedbdd1git diff --name-only 07548921affb0ab74b33ddf54d2c07ad6fef88a9..940207c0d4a20ea4204519fb9d1a08f6ddedbdd1uv run pytest tests/test_ble_unconfirmed_command.py tests/test_router.pyuv run pytest testsuv 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.