Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A
`Vehicles` (vehicle/vehicles.py) is a `dict[str, Vehicle]` with factory methods:
- `createFleet(vin)` → `VehicleFleet`
- `createSigned(vin)` → `VehicleSigned`
- `createBluetooth(vin, verify_commands=False)` → `VehicleBluetooth`
- `createBluetooth(vin, verify_commands=False, keepalive_interval=..., optimistic=False, raise_unconfirmed=True)` → `VehicleBluetooth`

Teslemetry/Tessie override `Vehicles` with their own vehicle classes (`TeslemetryVehicle`, `TessieVehicle`) extending `VehicleFleet` with service-specific commands (e.g., `closure()`, `seat_heater()` for Teslemetry; `wake()`, `lock()` for Tessie).

Expand Down Expand Up @@ -127,7 +127,8 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te
- **`charge_standard()` rejects `already_standard`**: live-verified - calling it while `charge_state().charge_limit_soc` already equals `charge_limit_soc_std` gets `{"result": False, "reason": "already_standard"}` rather than a no-op success. Not a library bug; callers/tests exercising this command need the limit to actually differ from the std preset first (e.g. via `charge_max_range()` or `set_charge_limit()`).
- **BLE media state-observability gotcha**: `MediaState.now_playing_artist/title` and all of `MediaDetailState` (`now_playing_album/station/source_string/elapsed/duration`) were observed empty/zero on the test car while Spotify was actively `Playing` at nonzero volume - these legacy fields are apparently only populated for certain sources (e.g. USB/Bluetooth), not Spotify. Don't assume `media_next_track`/`media_prev_track`/`media_next_fav`/`media_prev_fav` are state-observable via these readers; verify by ACK (`{"result": True}`) and pair with the inverse command when the fingerprint doesn't change. `audio_volume`/`media_playback_status` (for `adjust_volume`/`media_volume_up`/`media_volume_down`/`media_toggle_playback`) were populated correctly and are reliable provers.
- **BLE mutating-command timeout is inconclusive - never assume "the write didn't land"**: this corrects an earlier version of this note, which observed `adjust_volume` write timeouts on one rig leaving the car unmutated and concluded a write timeout means the write never landed. Later live testing disproved that as a general rule: `door_unlock` and `door_lock` each raised a timeout yet both physically executed - a VCSEC state read after each confirmed the lock state had flipped. Reads (state readers, `wake_up()`'s effect) came back reliably all night; only mutating VCSEC/RKE actions showed this false-negative pattern, most likely because the vehicle doesn't reliably return an ack `_send()` observes within the timeout, not because the write failed. Treat `BluetoothUnconfirmedCommand` (a `BluetoothTimeout` subclass) from any mutating BLE command as **inconclusive, not failure**: snapshot state before acting, then verify the outcome with a follow-up state read whenever a mutation times out. Never blind-retry a non-idempotent command (toggles like `media_toggle_playback`, volume steps, schedule add/remove) on timeout alone - see the retry-double-execution entry below for why the library's own retry has the same exposure. The `adjust_volume`/`TimeoutAPIError` root cause from the original observation is still unexplained; it just isn't evidence that timed-out writes never land. VCSEC actuations now use the shorter `_actuation_timeout` when their terminal ack is lost.
- **Opt-in `verify_commands` resolves the inconclusive mutating-timeout inside `VehicleBluetooth`**: constructor knob `verify_commands` (default off, `bluetooth.py`; threaded through `Vehicles`/`VehiclesBluetooth.createBluetooth`) that, on a `BluetoothUnconfirmedCommand` from a mutating command whose expected post-state is derivable from its args, reads the mapped prover state and returns a normal success dict if it matches or re-raises the unconfirmed timeout if not. Verify-on-timeout only (not verify-always): the post-PR-59 happy path returns on the terminal ack, so the prover read is paid only in the ambiguous case - "faster and still reliable". Intercepts at the `_sendVehicleSecurity`/`_sendInfotainment` seam (each called exactly once per public mutation; reads go through `_get*`, so a verification read never recurses into verification). Lives here rather than upstream because BLE is the only transport with the false-negative-ack problem; with it enabled, an unresolved timeout still reaches the caller as `BluetoothUnconfirmedCommand` (see below), so `VehicleRouter` never double-executes regardless of whether verification confirms, denies, or can't tell. Mapping tables and predicates are in `bluetooth.py` (`_vcsec_verify_plan`/`_INFOTAINMENT_VERIFY_PLANS`); only clearly-derivable, absolute commands are covered (lock/unlock, `set_charge_limit`, `set_charging_amps`, `adjust_volume` absolute, `set_temps`, `auto_conditioning_start/stop`). True toggles, relative volume steps, and ack-only actions have no plan and raise `BluetoothUnconfirmedCommand`. The VCSEC lock prover reads while asleep; INFO provers need the car awake and never wake it - an asleep-car read failure re-raises the unconfirmed timeout. See `docs/bluetooth_vehicles.md` for the user-facing table.
- **Opt-in `verify_commands` resolves the inconclusive mutating-timeout inside `VehicleBluetooth`**: constructor knob `verify_commands` (default off, `bluetooth.py`; threaded through `Vehicles`/`VehiclesBluetooth.createBluetooth`) that, on a `BluetoothUnconfirmedCommand` from a mutating command whose expected post-state is derivable from its args, reads the mapped prover state and returns a normal success dict if it matches, raises on a state mismatch, or leaves the outcome unresolved when there is no plan/read. Verify-on-timeout only (not verify-always): the post-PR-59 happy path returns on the terminal ack, so the prover read is paid only in the ambiguous case - "faster and still reliable". Intercepts at the `_sendVehicleSecurity`/`_sendInfotainment` seam (each called exactly once per public mutation; reads go through `_get*`, so a verification read never recurses into verification). Lives here rather than upstream because BLE is the only transport with the false-negative-ack problem; with it enabled and `raise_unconfirmed=True`, an unresolved timeout still reaches the caller as `BluetoothUnconfirmedCommand` (see below), so `VehicleRouter` never double-executes regardless of whether verification confirms, denies, or can't tell. Mapping tables and predicates are in `bluetooth.py` (`_vcsec_verify_plan`/`_INFOTAINMENT_VERIFY_PLANS`); only clearly-derivable, absolute commands are covered (lock/unlock, `set_charge_limit`, `set_charging_amps`, `adjust_volume` absolute, `set_temps`, `auto_conditioning_start/stop`). With default `raise_unconfirmed=True`, true toggles, relative volume steps, and ack-only actions have no plan and raise `BluetoothUnconfirmedCommand`. The VCSEC lock prover reads while asleep; INFO provers need the car awake and never wake it - an asleep-car read failure leaves the outcome unresolved. See `docs/bluetooth_vehicles.md` for the user-facing table.
- **`optimistic` and `raise_unconfirmed` are the two consumer-facing knobs on the same confirmation ladder as `verify_commands`**: both constructor/factory args (default off/on respectively - current behavior - threaded through `Vehicles`/`VehiclesBluetooth.create*` like `verify_commands`), both BLE-only, cloud paths untouched. `optimistic=True` short-circuits `_sendVehicleSecurity`/`_sendInfotainment` (`bluetooth.py`) to `_send_optimistic()`, which signs and writes but never waits for any reply - only a write/transport failure (`BluetoothTransportError`) can raise, and `verify_commands`/`raise_unconfirmed` are never consulted. `raise_unconfirmed=False` only changes what an *exhausted, ambiguous* ladder resolves to (best-effort success instead of raising `BluetoothUnconfirmedCommand`) - a car-side rejection, a `verify_commands` state mismatch, and write failures are unaffected and always raise regardless. `_resolve_timeout()` was reshaped to return `dict | None` (`None` = "couldn't even attempt verification", the ambiguous case) instead of always raising, so the mismatch branch (raises `timeout` directly) stays distinguishable from the ambiguous one (routed through `_unconfirmed_outcome()`, which applies `raise_unconfirmed`). `Router` needed zero changes: `optimistic`'s only exception (`BluetoothTransportError`) already fails over correctly as a generic exception, and `raise_unconfirmed=False`'s best-effort success is just a normal return value.
- **`BluetoothUnconfirmedCommand` (`exceptions.py`) is the ack-timeout-after-write case, and `Router` treats it specially**: `_sendVehicleSecurity`/`_sendInfotainment` (`bluetooth.py`, the mutating-command seam, unconditionally, not gated by `verify_commands`) wrap a caught `BluetoothTimeout` into this subclass before it can reach a caller - it means the write succeeded and the vehicle may have executed the command despite the lost ack. A plain read (`_getVehicleSecurity`/`_getInfotainment`) still raises unadorned `BluetoothTimeout` on the same kind of wait timeout, since a read has no side effect to be unconfirmed about, and a pre-write failure still raises the unrelated `BluetoothTransportError`. Subclassing `BluetoothTimeout` keeps every existing `except BluetoothTimeout` site (verify_commands, `pair()`'s fast path) working unchanged. `Router._dispatch` (`router/base.py`) special-cases this one exception type to skip its normal per-command failover and re-raise immediately instead - replaying an already-possibly-executed mutating command on the next backend is exactly the double-execution `verify_commands` above exists to prevent, so a BLE-primary/cloud-fallback `VehicleRouter` no longer risks it even with `verify_commands` off.
- **`wake_up()` is best-effort; confirm readiness with an INFO read**: `wake_up()` is a VCSEC actuation, so a terminal ack now returns promptly when observed, but `BluetoothUnconfirmedCommand` is still only an inconclusive wake signal, not command failure. Call it best-effort (catch and ignore timeout) and confirm readiness by retrying a cheap INFO read instead (see the boot-delay gotcha above for why the first INFO read still needs its own retry/backoff). Hold one connection across a whole batch of related commands rather than reconnecting between each - reconnecting costs ~123% more per operation with no demonstrated wake-preservation benefit from the connection alone.
- **The signed-command retry in `Commands._command` can double-execute a mutating command**: on an `OPERATIONSTATUS_WAIT` reply or an `INCORRECT_EPOCH`/`INVALID_TOKEN` fault, `_command` (`commands.py`) re-signs and re-sends the identical command, bounded at 3 attempts then a clean `{"result": False, "reason": "Too many retries"}` - the cap itself is safe and doesn't loop. Live-verified deterministically: a WAIT-then-OK sequence produces 2 physical sends of the same command on the wire. Combined with the mutating-timeout-is-inconclusive gotcha above (a command can execute despite a WAIT/fault reply), this retry is a latent double-apply window. Harmless for a naturally idempotent command (lock/unlock), a real correctness risk for toggles and step commands (`media_toggle_playback`, `media_volume_up`/`down`, schedule add/remove) - verify those by absolute state after the call, never by counting invocations or trusting the retry to be safe.
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,13 @@ disable keepalive when vehicle sleep is preferred.

BLE connect/notify and GATT write failures from `VehicleBluetooth` raise
`BluetoothTransportError`, a `TeslaFleetError` subclass, with the original
transport exception chained as `__cause__`. A response-wait timeout from a
mutating BLE command raises `BluetoothUnconfirmedCommand`, a
transport exception chained as `__cause__`. By default, a response-wait timeout
from a mutating BLE command raises `BluetoothUnconfirmedCommand`, a
`BluetoothTimeout` subclass, because the vehicle may have executed it despite a
lost acknowledgement. Catch `TeslaFleetError` to handle Bluetooth transport
lost acknowledgement. The BLE-only `optimistic` and `raise_unconfirmed`
factory options can instead resolve some ambiguous timeouts as best-effort
successes; see [Bluetooth for Vehicles](docs/bluetooth_vehicles.md) for the
full confirmation ladder. Catch `TeslaFleetError` to handle Bluetooth transport
failures (including `bleak.exc.BleakError` and builtin `TimeoutError` from
ESPHome proxies) and response-wait timeouts through the same library error
hierarchy.
Expand Down Expand Up @@ -231,7 +234,7 @@ await router.set_operation(...) # local first, cloud on failure

Enable `DEBUG` logging for `tesla_fleet_api` to see which backend served a routed call and why failover happened.

> **Warning:** Because a failed call is replayed on the next backend, a non-idempotent command (e.g. `honk_horn`, `actuate_trunk`, `door_unlock`, `charge_start`) that fails _mid-flight_ — after a backend may have already partially applied it — can be **double-executed** (or executed more than once across a longer chain) when it is retried on the next backend. This is a deliberate tradeoff of per-command failover. `BluetoothUnconfirmedCommand` is the exception: it propagates without failover because the BLE command may already have executed. When the primary is `VehicleBluetooth`, pass `verify_commands=True` to resolve supported mutating command timeouts by state before they reach the router; callers needing exactly-once semantics for other commands should gate dispatch with an explicit `health` check or call the underlying backends directly.
> **Warning:** Because a failed call is replayed on the next backend, a non-idempotent command (e.g. `honk_horn`, `actuate_trunk`, `door_unlock`, `charge_start`) that fails _mid-flight_ — after a backend may have already partially applied it — can be **double-executed** (or executed more than once across a longer chain) when it is retried on the next backend. This is a deliberate tradeoff of per-command failover. `BluetoothUnconfirmedCommand` is the exception: it propagates without failover because the BLE command may already have executed. When the primary is `VehicleBluetooth`, pass `verify_commands=True` to resolve supported mutating command timeouts by state before they reach the router, or `raise_unconfirmed=False` only when a best-effort success is acceptable; callers needing exactly-once semantics for other commands should gate dispatch with an explicit `health` check or call the underlying backends directly.
>
> Dispatch is implemented via `__getattr__`, which does not proxy dunder methods, so `async with Router(...)` does **not** manage a backend's BLE connection lifecycle (`__aenter__`/`__aexit__`). Commands still auto-connect on send; for explicit connect/disconnect reach through `router.primary` (or `router.backends`).

Expand Down
Loading
Loading