diff --git a/AGENTS.md b/AGENTS.md index fbdd635..b0c2552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A `VehicleSigned` uses multiple inheritance: `Commands` for signed command logic, `VehicleFleet` for data endpoints and fallback. -`Router` (router.py) is an entity-agnostic composition wrapper (not part of the inheritance chain) that chains an ordered list of two-or-more backends sharing a common method surface and dispatches each method call down the chain with automatic per-command failover: it tries the first backend that has the method and, on any exception, retries the same call on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails, `AttributeError` only if none has the method). Non-callable attributes resolve to the first backend that has them. The constructor is `Router(primary, secondary, *more_backends, health=None)` — the two-argument form is fully backward compatible. The health check (`bool` | sync callable | async callable returning `bool`; omitted = attempt primary, fail over on exception with no probe) gates **only the primary** (the first backend); the rest of the chain is reached purely through per-command failover — there is deliberately no per-backend health matrix. Note the double-execution caveat: a non-idempotent command that fails mid-flight can be re-run on the next backend. +`Router` (router.py) is an entity-agnostic composition wrapper (not part of the inheritance chain) that chains an ordered list of two-or-more backends sharing a common method surface and dispatches each method call down the chain with automatic per-command failover: it tries the first backend that has the method and, on any exception except `BluetoothUnconfirmedCommand`, retries the same call on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails, `AttributeError` only if none has the method). Non-callable attributes resolve to the first backend that has them. The constructor is `Router(primary, secondary, *more_backends, health=None)` — the two-argument form is fully backward compatible. The health check (`bool` | sync callable | async callable returning `bool`; omitted = attempt primary, fail over on exception with no probe) gates **only the primary** (the first backend); the rest of the chain is reached purely through per-command failover — there is deliberately no per-backend health matrix. Note the double-execution caveat: a non-idempotent command that fails mid-flight can be re-run on the next backend, except for `BluetoothUnconfirmedCommand`, which propagates without replay. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses of `Router`. `VehicleRouter(bluetooth_primary, teslemetry_secondary)` pairs a `VehicleBluetooth` primary with a cloud (`TeslemetryVehicle`) secondary; `EnergySiteRouter(local_energysite, teslemetry_energysite)` pairs a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback. All three live in the top-level `router/` package — `Router` (and shared helpers) in `router/base.py`, `VehicleRouter` in `router/vehicle.py`, `EnergySiteRouter` in `router/energysite.py`, re-exported from `router/__init__.py` (importable as `tesla_fleet_api.router.Router` etc.) and, for backward compatibility, also re-exported from `tesla/__init__.py` (`tesla_fleet_api.tesla.Router`). They have no factory on the `Vehicles`/`EnergySites` collections. @@ -99,7 +99,7 @@ No release-please or version-bump automation. To ship: bump `version` in `pyproj `exceptions.py` maps HTTP status codes and error keys to specific exception classes. `raise_for_status()` parses responses and raises the appropriate exception. Signed command faults have separate hierarchies: `TeslaFleetInformationFault`, `TeslaFleetMessageFault`, `SignedMessageInformationFault`, `WhitelistOperationStatus`. -All exceptions inherit from `TeslaFleetError(BaseException)`, deliberately **not** `Exception` — a bare `except Exception` (e.g. in retry/backoff loops around BLE reads) silently fails to catch `BluetoothTimeout` and every other library error. Catch `TeslaFleetError` (or `BaseException`) explicitly. `VehicleBluetooth` wraps transport-layer failures (`connect`/`connect_if_needed`, notification setup, the GATT write in `_send`) in `BluetoothTransportError`, a `TeslaFleetError` subclass chaining the original transport exception as its cause — so `except TeslaFleetError` alone now catches BLE transport failures too, not just the response-wait `BluetoothTimeout`. These catch sites deliberately catch **both** `bleak.exc.BleakError` and builtin `TimeoutError`: bleak-esphome converts an aioesphomeapi GATT/connect/notify timeout into a bare `TimeoutError` (not a `BleakError`), which would otherwise escape the wrap as a non-`TeslaFleetError`. +All exceptions inherit from `TeslaFleetError(BaseException)`, deliberately **not** `Exception` — a bare `except Exception` (e.g. in retry/backoff loops around BLE reads) silently fails to catch `BluetoothTimeout` and every other library error. Catch `TeslaFleetError` (or `BaseException`) explicitly. `VehicleBluetooth` wraps transport-layer failures (`connect`/`connect_if_needed`, notification setup, the GATT write in `_send`) in `BluetoothTransportError`, a `TeslaFleetError` subclass chaining the original transport exception as its cause — so `except TeslaFleetError` alone now catches BLE transport failures too, not just response-wait `BluetoothTimeout`/`BluetoothUnconfirmedCommand`. These catch sites deliberately catch **both** `bleak.exc.BleakError` and builtin `TimeoutError`: bleak-esphome converts an aioesphomeapi GATT/connect/notify timeout into a bare `TimeoutError` (not a `BleakError`), which would otherwise escape the wrap as a non-`TeslaFleetError`. ### Protobuf @@ -126,11 +126,12 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te - **`set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` args are dead**: live-verified - `ScheduledDepartureAction` (`proto/car_server.proto`) has no fields for them, only `preconditioning_times`/`off_peak_charging_times` (weekday-recurrence only, no on/off). Passing `preconditioning_enabled=False` has no effect on the vehicle's observed state. Not a library bug to "fix" without a wider protocol capability; document, don't rely on these args to gate the feature. - **`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 `BluetoothTimeout` 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 a `BluetoothTimeout` 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 `BluetoothTimeout` 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 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, `VehicleRouter`'s blind BLE->cloud failover no longer double-executes non-idempotent commands (a verified-executed command returns success so no failover fires; a verified-not-executed one raises so failover is genuinely safe - Router needs no changes). 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 re-raise unchanged. 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 original timeout. See `docs/bluetooth_vehicles.md` for the user-facing table. -- **`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 a `BluetoothTimeout` 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. +- **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. +- **`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. -- **`expects_data` splits BLE reply-waiting: VCSEC actuations return on the terminal ack**: a VCSEC read replies with a bare ACK **then** a data frame, but a VCSEC actuation (RKE/closure/wake via `_sendVehicleSecurity`) replies with a **single bare ACK only** - `_send` cannot tell the two apart at transport level, so the caller declares it. `_sendVehicleSecurity` passes `expects_data=False` down through `_command` into `_send` (`commands.py`/`bluetooth.py`); everything else (VCSEC reads via `_getVehicleSecurity`, all infotainment via `_send/_getInfotainment`, `_handshake`, `pair`) keeps the default `expects_data=True`. With `expects_data=False`, `_send` returns immediately on the matching ACK instead of waiting out `_ack_followup_timeout` (was a flat ~2s tax on every VCSEC actuation), and on a lost ack it raises `BluetoothTimeout` after the shorter `_actuation_timeout` (2s) rather than `_default_timeout` (5s) - the verify-by-state contract makes a longer wait pointless. Infotainment actuations never paid the tax (their `actionStatus` rides in `protobuf_message_as_bytes`, so `_send` returns on that data frame). The WAIT/fault retry threads `expects_data` through unchanged, so the double-execute exposure noted above is unaffected. +- **`expects_data` splits BLE reply-waiting: VCSEC actuations return on the terminal ack**: a VCSEC read replies with a bare ACK **then** a data frame, but a VCSEC actuation (RKE/closure/wake via `_sendVehicleSecurity`) replies with a **single bare ACK only** - `_send` cannot tell the two apart at transport level, so the caller declares it. `_sendVehicleSecurity` passes `expects_data=False` down through `_command` into `_send` (`commands.py`/`bluetooth.py`); everything else (VCSEC reads via `_getVehicleSecurity`, all infotainment via `_send/_getInfotainment`, `_handshake`, `pair`) keeps the default `expects_data=True`. With `expects_data=False`, `_send` returns immediately on the matching ACK instead of waiting out `_ack_followup_timeout` (was a flat ~2s tax on every VCSEC actuation), and on a lost ack the public mutating-command wrapper raises `BluetoothUnconfirmedCommand` after the shorter `_actuation_timeout` (2s) rather than `_default_timeout` (5s) - the verify-by-state contract makes a longer wait pointless. Infotainment actuations never paid the tax (their `actionStatus` rides in `protobuf_message_as_bytes`, so `_send` returns on that data frame). The WAIT/fault retry threads `expects_data` through unchanged, so the double-execute exposure noted above is unaffected. - **`navigation_gps_request`'s `order` param is a raw int, not a callable enum**: `commands.py` used to build it as `NavigationGpsRequest.RemoteNavTripOrder(order)`, treating the protobuf nested-enum wrapper (`EnumTypeWrapper`) as if it were a callable Python `enum.IntEnum` class - it isn't, so every call raised `TypeError` before any message was sent (found live during PR-8; this method had never been exercised over BLE before). Fixed to pass `order=order` directly (matching the working sibling `navigation_gps_destination_request`), which protobuf accepts as a bare int for an enum field at runtime. - **`ReassemblingBuffer` resets on a >1s inter-chunk gap, not just on decode failure**: `bluetooth.py`'s `ReassemblingBuffer.receive_data` discards any in-progress partial frame if the next chunk arrives more than `STALE_CHUNK_TIMEOUT` (1s) after the previous one, mirroring Tesla's official Go SDK (`teslamotors/vehicle-command`, `pkg/connector/ble/ble.go`'s `rxTimeout`). Without this, a chunk dropped mid-message left a stale partial in the buffer that got prepended to the next message, corrupting it until a lucky decode failure resynced. This is a frame-integrity hardening, not a fix for the separate ack-loss behavior documented above (that's a stalled/silent link, which no buffer-side reset can recover). - **`pair()` confirms whitelisting two ways: one-shot reply OR verify-by-state poll**: the whitelist-op success is a single VCSEC frame, and it lands on a dead session (lost forever) if the BLE link cycles while the user walks to the car to approve - live-observed on HA/macOS CoreBluetooth, where `tesla_fleet_api` logged "Reconnecting to S..." every ~100s during a pending pair, and the plain one-shot wait hung despite car-side completion. `pair()` (`bluetooth.py`) keeps the reply as the fast path (waits one `poll_interval` for it) but, on a lost reply, polls `_pair_probe()` every `poll_interval` until an overall `timeout` (default 300s) elapses. The probe is a VCSEC `_handshake` with our own public key: it succeeds only once the key is whitelisted and faults `NotOnWhitelistFault` until then - `_pair_probe` maps any `TeslaFleetError` (incl. transport failures from a mid-wait reconnect) to "not yet", so polling survives reconnects. The whitelist op is written **exactly once** - never re-sent - because a re-send re-prompts the user (and the retry/double-execute hazard documented above applies). Deadline with neither path confirming raises a typed `BluetoothTimeout`. Default behavior, no new knob (`poll_interval` is a defaulted param). diff --git a/README.md b/README.md index c38e08f..03d12da 100644 --- a/README.md +++ b/README.md @@ -174,14 +174,17 @@ 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__`. Catch `TeslaFleetError` to handle -Bluetooth transport failures (including `bleak.exc.BleakError` and builtin -`TimeoutError` from ESPHome proxies) and `BluetoothTimeout` response-wait -timeouts through the same library error hierarchy. +transport exception chained as `__cause__`. 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 +failures (including `bleak.exc.BleakError` and builtin `TimeoutError` from +ESPHome proxies) and response-wait timeouts through the same library error +hierarchy. ### Routing and Failover -The `Router` class composes an ordered list of two-or-more backends that share a common method surface and dispatches each method call down the chain, automatically failing over on error. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise: +The `Router` class composes an ordered list of two-or-more backends that share a common method surface and dispatches each method call down the chain, automatically failing over on most errors. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise: ```python import asyncio @@ -211,7 +214,7 @@ async def main(): asyncio.run(main()) ``` -The constructor is `Router(primary, secondary, *more_backends, health=None)`; the two-argument form shown above is fully backward compatible, and any number of extra backends may follow to extend the chain. Each call is tried on the first backend that has the method and, on any exception, retried on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails). Non-callable attributes (e.g. `vin`) resolve to the first backend that has them. +The constructor is `Router(primary, secondary, *more_backends, health=None)`; the two-argument form shown above is fully backward compatible, and any number of extra backends may follow to extend the chain. Each call is tried on the first backend that has the method and, on any exception except `BluetoothUnconfirmedCommand`, retried on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails). Non-callable attributes (e.g. `vin`) resolve to the first backend that has them. By default the router attempts the primary and fails over on any error, with no up-front probe. You can also pass an explicit `health` check — a `bool`, a sync callable, or an async callable returning `bool` — to decide up front whether to route to the primary or skip straight to the rest of the chain. The health check gates **only the primary** (the first backend); later backends are reached purely through per-command failover. @@ -228,7 +231,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. When the primary is `VehicleBluetooth`, pass `verify_commands=True` to resolve supported mutating command timeouts by state before failover; 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; 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`). diff --git a/docs/bluetooth_vehicles.md b/docs/bluetooth_vehicles.md index 5803c00..343a5ad 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -119,13 +119,14 @@ async def main(): asyncio.run(main()) ``` -`wake_up()` is best-effort over BLE: a `BluetoothTimeout` from the wake request -can be a false negative even when the vehicle wakes successfully. Confirm -readiness by retrying a cheap INFO-domain read, such as `charge_state()`, with -backoff. The infotainment computer can also take longer to become ready than -the vehicle-security computer, so INFO-domain reads immediately after waking -should retry `BluetoothTimeout` with backoff. Keep one BLE connection open -across related commands when possible instead of reconnecting for each command. +`wake_up()` is best-effort over BLE: a `BluetoothUnconfirmedCommand` from the +wake request can be a false negative even when the vehicle wakes successfully +(and still matches `except BluetoothTimeout`). Confirm readiness by retrying a +cheap INFO-domain read, such as `charge_state()`, with backoff. The +infotainment computer can also take longer to become ready than the +vehicle-security computer, so INFO-domain reads immediately after waking should +retry `BluetoothTimeout` with backoff. Keep one BLE connection open across +related commands when possible instead of reconnecting for each command. `VehicleBluetooth` raises `BluetoothTransportError`, a `TeslaFleetError` subclass, when the BLE connection, notification setup, or GATT command write @@ -133,9 +134,19 @@ fails before a vehicle response can be awaited. The original transport exception is available as the exception's `__cause__`; this includes `bleak.exc.BleakError` and builtin `TimeoutError` from ESPHome proxy connect, notify, or write timeouts. Catch `TeslaFleetError` to handle both transport -failures and response-wait `BluetoothTimeout` failures with one library error -hierarchy, or catch `BluetoothTransportError` separately when you need to -distinguish a transport failure from a vehicle timeout. +failures and response-wait timeout failures with one library error hierarchy, +or catch `BluetoothTransportError` separately when you need to distinguish a +transport failure - the command never reached the vehicle - from a vehicle +timeout after the command or request was written. + +A response-wait timeout for a *mutating* command (RKE/closure actions, +HVAC/media/charging commands, `wake_up()`) raises `BluetoothUnconfirmedCommand` +instead of plain `BluetoothTimeout` - see "Mutating Command Timeouts" below. A +response-wait timeout for anything else (a state read) still raises plain +`BluetoothTimeout`. `BluetoothUnconfirmedCommand` subclasses `BluetoothTimeout`, +so existing `except BluetoothTimeout` handling keeps working; catch +`BluetoothUnconfirmedCommand` separately when you need to tell "the command may +have executed" apart from "nothing happened." BLE response chunks are reassembled with the same stale-frame behavior as Tesla's vehicle-command BLE connector: if a partial frame sits idle for more @@ -145,11 +156,15 @@ the next response, but it does not change command acknowledgement timeouts. ## Mutating Command Timeouts -A `BluetoothTimeout` from a mutating BLE command is inconclusive, not proof that -the command failed. The vehicle can apply the command even when its -acknowledgement does not reach the client. For commands that change vehicle -state, snapshot the relevant state before acting and verify the outcome with a -follow-up state read after any timeout. +A `BluetoothUnconfirmedCommand` from a mutating BLE command is unconfirmed, not +proof that the command failed. The vehicle can apply the command even when its +acknowledgement does not reach the client - `door_lock()`/`door_unlock()` have +both been observed to execute despite this exception. For commands that change +vehicle state, snapshot the relevant state before acting and verify the outcome +with a follow-up state read after any timeout. Never blind-retry the same +command, and never replay it on a fallback transport (e.g. a BLE-primary/cloud +fallback router) - the safe response to an unconfirmed outcome is to verify, not +to retry. VCSEC actuations, such as RKE, closure, and wake requests, return as soon as their terminal acknowledgement arrives because no data frame follows it. If that @@ -175,14 +190,14 @@ case. When a mutating command times out and its expected post-state can be derived from its own arguments, the same held connection reads the mapped prover state and either returns a normal success result (`{"response": {"result": True, -"reason": ""}}`) when the state matches or re-raises the `BluetoothTimeout` when -it does not. The read rides the existing connection and never wakes the vehicle; -if an infotainment prover cannot be read because the car is asleep, the original -timeout is re-raised. +"reason": ""}}`) when the state matches or re-raises the +`BluetoothUnconfirmedCommand` when it does not. The read rides the existing +connection and never wakes the vehicle; if an infotainment prover cannot be +read because the car is asleep, the unconfirmed command timeout is re-raised. Commands whose outcome cannot be derived or read - true toggles, relative volume steps, and ack-only actions such as `flash_lights()` or `trigger_homelink()` - -re-raise the timeout unchanged, exactly as with verification off. Currently +raise `BluetoothUnconfirmedCommand`, exactly as with verification off. Currently verified commands and their provers: | Command | Prover | Confirmed when | @@ -391,8 +406,8 @@ such as `now_playing_artist`, `now_playing_title`, and the so track/favorite navigation is best verified by the command acknowledgement and paired with the inverse command when an exact state fingerprint is unavailable. -If a BLE write times out, re-read the relevant state before assuming whether the -command applied. A timeout while waiting for the GATT write response is not +If a BLE command times out after the write, re-read the relevant state before +assuming whether the command applied. A `BluetoothUnconfirmedCommand` is not enough to prove either failure or success. `remote_boombox(sound)` also uses the INFO-domain signed-command transport and @@ -416,7 +431,7 @@ Each command logs one terse, grep-friendly line: ``` command=RKE_ACTION_LOCK transport=bluetooth result=True reason= command=set_charge_limit transport=teslemetry result=True reason= -command=mediaPlayAction transport=bluetooth result=error error=BluetoothTimeout: Bluetooth command timed out waiting for vehicle response. +command=mediaPlayAction transport=bluetooth result=error error=BluetoothUnconfirmedCommand: Bluetooth command timed out waiting for an ack after being written to the vehicle; it may have executed anyway. ``` `transport` is `bluetooth`, `fleet`, `teslemetry`, or `tessie`. For BLE signed @@ -424,7 +439,10 @@ commands, `command` is the underlying VCSEC/infotainment field name (e.g. `RKE_ACTION_LOCK`, `chargingSetLimitAction`), not the Python method name; for REST commands it is the endpoint's final path segment (e.g. `set_charge_limit`). For BLE commands run with `verify_commands=True`, a resolved timeout logs a -second line with `verify_commands=resolved` and the confirmed result. `Router` -additionally logs `command=... backend= result=...` for each -backend it tries, so a BLE-primary/cloud-fallback setup shows exactly which -backend served each call and why a failover happened. +second line with `verify_commands=resolved` and the confirmed result; an +unresolved timeout logs `verify_commands=unresolved` before the +`BluetoothUnconfirmedCommand` propagates. `Router` additionally logs +`command=... backend= result=...` for each backend it tries, or +`result=unconfirmed` when it stops instead of failing over, so a +BLE-primary/cloud-fallback setup shows exactly which backend served each call +and why a failover happened. diff --git a/tesla_fleet_api.egg-info/PKG-INFO b/tesla_fleet_api.egg-info/PKG-INFO index 926f0e3..a14300c 100644 --- a/tesla_fleet_api.egg-info/PKG-INFO +++ b/tesla_fleet_api.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: tesla_fleet_api -Version: 1.5.2 +Version: 1.6.4 Summary: Tesla Fleet API library for Python Author-email: Brett Adams License-Expression: Apache-2.0 @@ -189,21 +189,31 @@ asyncio.run(main()) For more detailed examples, see [Bluetooth for Vehicles](docs/bluetooth_vehicles.md). -BLE connect and GATT write failures from `VehicleBluetooth` raise +`VehicleBluetooth` keeps a held BLE connection alive during idle periods by +default with a passive GATT read about every 20 seconds. Pass +`keepalive_interval=None` (or `0`) when creating the vehicle to disable it; +leaving it enabled can keep an already-awake car awake longer, so disconnect or +disable keepalive when vehicle sleep is preferred. + +BLE connect/notify and GATT write failures from `VehicleBluetooth` raise `BluetoothTransportError`, a `TeslaFleetError` subclass, with the original -`bleak.exc.BleakError` chained as `__cause__`. Catch `TeslaFleetError` to -handle Bluetooth transport failures and `BluetoothTimeout` response-wait -timeouts through the same library error hierarchy. +transport exception chained as `__cause__`. 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 +failures (including `bleak.exc.BleakError` and builtin `TimeoutError` from +ESPHome proxies) and response-wait timeouts through the same library error +hierarchy. ### Routing and Failover -The `Router` class composes an ordered list of two-or-more backends that share a common method surface and dispatches each method call down the chain, automatically failing over on error. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise: +The `Router` class composes an ordered list of two-or-more backends that share a common method surface and dispatches each method call down the chain, automatically failing over on most errors. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise: ```python import asyncio import aiohttp from tesla_fleet_api import TeslaBluetooth, Teslemetry -from tesla_fleet_api.tesla.router import VehicleRouter +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.exceptions import TeslaFleetError async def main(): @@ -211,13 +221,13 @@ async def main(): # Primary: local Bluetooth tesla_bluetooth = TeslaBluetooth() await tesla_bluetooth.get_private_key("path/to/private_key.pem") - primary = tesla_bluetooth.vehicles.create("") + primary = tesla_bluetooth.vehicles.create("", verify_commands=True) - # Fallback: Teslemetry cloud + # Secondary (fallback): Teslemetry cloud teslemetry = Teslemetry(access_token="", session=session) - fallback = teslemetry.vehicles.create("") + secondary = teslemetry.vehicles.create("") - vehicle = VehicleRouter(primary, fallback) + vehicle = VehicleRouter(primary, secondary) try: await vehicle.wake_up() @@ -227,25 +237,45 @@ async def main(): asyncio.run(main()) ``` -The constructor is `Router(primary, fallback, *more_backends, health=None)`; the two-argument form shown above is fully backward compatible, and any number of extra backends may follow to extend the chain. Each call is tried on the first backend that has the method and, on any exception, retried on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails). Non-callable attributes (e.g. `vin`) resolve to the first backend that has them. +The constructor is `Router(primary, secondary, *more_backends, health=None)`; the two-argument form shown above is fully backward compatible, and any number of extra backends may follow to extend the chain. Each call is tried on the first backend that has the method and, on any exception except `BluetoothUnconfirmedCommand`, retried on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails). Non-callable attributes (e.g. `vin`) resolve to the first backend that has them. By default the router attempts the primary and fails over on any error, with no up-front probe. You can also pass an explicit `health` check — a `bool`, a sync callable, or an async callable returning `bool` — to decide up front whether to route to the primary or skip straight to the rest of the chain. The health check gates **only the primary** (the first backend); later backends are reached purely through per-command failover. `EnergySiteRouter` follows the same pattern for energy sites, pairing a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback: ```python -from tesla_fleet_api.tesla.router import EnergySiteRouter +from tesla_fleet_api.router import EnergySiteRouter router = EnergySiteRouter(local_energysite, teslemetry_energysite) await router.set_operation(...) # local first, cloud on failure ``` -`Router`, `VehicleRouter`, and `EnergySiteRouter` are all importable from `tesla_fleet_api.tesla.router` (and from `tesla_fleet_api.tesla`). +`Router`, `VehicleRouter`, and `EnergySiteRouter` are all importable from `tesla_fleet_api.router` (and, for backward compatibility, from `tesla_fleet_api.tesla`). + +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. Callers needing exactly-once semantics for such 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; 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`). +### Debug Logging + +Enable the `tesla_fleet_api` logger at `DEBUG` to see each command's command +name, transport/backend, and result. In standalone scripts, configure a handler +first: + +```python +import logging + +logging.basicConfig(level=logging.DEBUG) +logging.getLogger("tesla_fleet_api").setLevel(logging.DEBUG) +``` + +Command log lines use `transport=bluetooth`, `fleet`, `teslemetry`, or `tessie`. +Routers also emit `backend=` lines for each backend tried. See +[Bluetooth for Vehicles](docs/bluetooth_vehicles.md#troubleshooting-enable-debug-logging) +for examples and the signed-command naming details. + ### Teslemetry The `Teslemetry` class provides methods to interact with the Teslemetry service. Here's a basic example: diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index 1af3ff1..7c95146 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -5,11 +5,16 @@ tesla_fleet_api/__init__.py tesla_fleet_api/const.py tesla_fleet_api/exceptions.py tesla_fleet_api/py.typed +tesla_fleet_api/util.py tesla_fleet_api.egg-info/PKG-INFO tesla_fleet_api.egg-info/SOURCES.txt tesla_fleet_api.egg-info/dependency_links.txt tesla_fleet_api.egg-info/requires.txt tesla_fleet_api.egg-info/top_level.txt +tesla_fleet_api/router/__init__.py +tesla_fleet_api/router/base.py +tesla_fleet_api/router/energysite.py +tesla_fleet_api/router/vehicle.py tesla_fleet_api/tesla/__init__.py tesla_fleet_api/tesla/bluetooth.py tesla_fleet_api/tesla/charging.py @@ -17,7 +22,6 @@ tesla_fleet_api/tesla/energysite.py tesla_fleet_api/tesla/fleet.py tesla_fleet_api/tesla/oauth.py tesla_fleet_api/tesla/partner.py -tesla_fleet_api/tesla/router.py tesla_fleet_api/tesla/tesla.py tesla_fleet_api/tesla/user.py tesla_fleet_api/tesla/vehicle/__init__.py @@ -39,6 +43,8 @@ tesla_fleet_api/tesla/vehicle/proto/keys_pb2.py tesla_fleet_api/tesla/vehicle/proto/keys_pb2.pyi tesla_fleet_api/tesla/vehicle/proto/managed_charging_pb2.py tesla_fleet_api/tesla/vehicle/proto/managed_charging_pb2.pyi +tesla_fleet_api/tesla/vehicle/proto/session_pb2.py +tesla_fleet_api/tesla/vehicle/proto/session_pb2.pyi tesla_fleet_api/tesla/vehicle/proto/signatures_pb2.py tesla_fleet_api/tesla/vehicle/proto/signatures_pb2.pyi tesla_fleet_api/tesla/vehicle/proto/universal_message_pb2.py @@ -50,11 +56,32 @@ tesla_fleet_api/tesla/vehicle/proto/vehicle_pb2.pyi tesla_fleet_api/teslemetry/__init__.py tesla_fleet_api/teslemetry/energysite.py tesla_fleet_api/teslemetry/teslemetry.py -tesla_fleet_api/teslemetry/vehicles.py +tesla_fleet_api/teslemetry/vehicle.py tesla_fleet_api/tessie/__init__.py tesla_fleet_api/tessie/tessie.py -tesla_fleet_api/tessie/vehicles.py +tesla_fleet_api/tessie/vehicle.py +tests/test_auto_seat_climate.py +tests/test_ble_charging_commands.py +tests/test_ble_climate_commands.py +tests/test_ble_command_verification.py +tests/test_ble_expects_data.py +tests/test_ble_keepalive.py +tests/test_ble_message_routing.py +tests/test_ble_mocked_closures_locks.py +tests/test_ble_mocked_commands.py +tests/test_ble_mocked_media_commands.py +tests/test_ble_mocked_state_readers.py +tests/test_ble_nav_misc_commands.py +tests/test_ble_pair.py +tests/test_ble_reassembling_buffer.py +tests/test_ble_send_transport.py +tests/test_ble_unconfirmed_command.py tests/test_command_counter_lock.py +tests/test_command_logging.py +tests/test_cross_transport_parity.py +tests/test_find_vehicle_scan_filter.py +tests/test_firmware_at_least.py tests/test_fleet_auth_refresh.py +tests/test_power_mode_commands.py tests/test_router.py tests/test_tessie_vehicle_params.py \ No newline at end of file diff --git a/tesla_fleet_api/exceptions.py b/tesla_fleet_api/exceptions.py index 2d28f7a..51e6484 100644 --- a/tesla_fleet_api/exceptions.py +++ b/tesla_fleet_api/exceptions.py @@ -28,6 +28,29 @@ class BluetoothTimeout(TeslaFleetError): message = "Bluetooth command timed out waiting for vehicle response." +class BluetoothUnconfirmedCommand(BluetoothTimeout): + """A mutating Bluetooth command timed out after it was written to the vehicle. + + The write succeeded, so the vehicle may have executed the command even + though its ack was lost - lock/unlock have both been observed to execute + despite this exception. Treat the outcome as unknown, not failed: verify + by reading state back when possible, and never blind-retry or re-issue + the same command on another transport, since it may already have run. + + Subclasses ``BluetoothTimeout`` so existing ``except BluetoothTimeout`` + handling still catches it, while remaining distinguishable from it (and + from ``BluetoothTransportError``, the genuine pre-write transport + failure) for callers that want to react to the ambiguity specifically - + e.g. a BLE-primary/cloud-fallback router should not fail over on this + exception, since failing over risks double-executing the command. + """ + + message = ( + "Bluetooth command timed out waiting for an ack after being written to " + "the vehicle; it may have executed anyway." + ) + + class BluetoothTransportError(TeslaFleetError): """The Bluetooth transport (connect, notify, or GATT write) failed before a vehicle response could be awaited.""" diff --git a/tesla_fleet_api/router/base.py b/tesla_fleet_api/router/base.py index 0ddd7e4..5509f9a 100644 --- a/tesla_fleet_api/router/base.py +++ b/tesla_fleet_api/router/base.py @@ -11,7 +11,7 @@ ) from tesla_fleet_api.const import LOGGER -from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.exceptions import BluetoothUnconfirmedCommand, TeslaFleetError PrimaryT = TypeVar("PrimaryT") SecondaryT = TypeVar("SecondaryT") @@ -49,13 +49,14 @@ class Router(Generic[PrimaryT, SecondaryT]): backend that has them. Dispatch to a callable performs *per-command* failover: the backends that - expose the method are attempted in order, and if one raises any exception (a - connection failure or a mid-command transport error such as a write/notify - failure or a disconnect), the same call is automatically retried on the next - backend that has it, with the same arguments. The error only propagates when - every applicable backend fails, in which case the last error is raised. - Each attempted backend emits a ``DEBUG`` log line with the routed command - name, backend class, and success/error result. + expose the method are attempted in order, and if one raises any exception + other than ``BluetoothUnconfirmedCommand`` (a connection failure or a + mid-command transport error such as a write/notify failure or a disconnect), + the same call is automatically retried on the next backend that has it, with + the same arguments. The error only propagates when every applicable backend + fails, in which case the last error is raised. Each attempted backend emits + a ``DEBUG`` log line with the routed command name, backend class, and + success/error result. .. warning:: @@ -70,6 +71,13 @@ class Router(Generic[PrimaryT, SecondaryT]): primary skips the primary entirely rather than replaying down the chain) or call the underlying backends directly. + ``BluetoothUnconfirmedCommand`` (a lost ack for a mutating BLE command + already written to the vehicle - see that exception's docstring) is the + one exception per-command failover deliberately does **not** replay: the + command may already have executed, so trying the next backend would risk + double-executing it. It propagates to the caller unchanged instead of + triggering failover, on any backend in the chain. + The health check may be provided as a ``bool``, a sync callable, or an async callable returning ``bool``. It gates **only the first backend** (the primary): when an explicit check evaluates ``False`` the primary is skipped @@ -151,6 +159,18 @@ async def _routed(*args: Any, **kwargs: Any) -> Any: for backend, attr in targets[start:]: try: result = await _maybe_await(attr(*args, **kwargs)) + except BluetoothUnconfirmedCommand as e: + # The command may have already executed on this backend; + # replaying it on the next one risks double-executing it. + # Surface the ambiguity to the caller instead of failing over. + LOGGER.debug( + "command=%s backend=%s result=unconfirmed error=%s: %s", + name, + type(backend).__name__, + type(e).__name__, + e, + ) + raise except (Exception, TeslaFleetError) as e: # noqa: BLE001 - any failure -> next backend last_exc = e LOGGER.debug( diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index e9100ab..dcd7a7b 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -20,6 +20,7 @@ WHITELIST_OPERATION_STATUS, BluetoothTimeout, BluetoothTransportError, + BluetoothUnconfirmedCommand, TeslaFleetError, WhitelistOperationStatus, ) @@ -209,11 +210,11 @@ def discard_packet(self): # A lost ack from a mutating BLE command is inconclusive: the vehicle may have # executed it anyway. When ``verify_commands`` is on, a timed-out mutation whose # outcome can be derived from its own arguments is confirmed by reading the -# mapped state instead of surfacing the ambiguous timeout. A verify "plan" pairs -# the state reader to call with a predicate that checks the observed state -# against the requested value. Commands absent from these tables - true toggles, -# relative steps, and ack-only actions whose outcome cannot be derived from the -# request - have no plan and re-raise the timeout unchanged. +# mapped state instead of surfacing the ambiguous unconfirmed timeout. A verify +# "plan" pairs the state reader to call with a predicate that checks the +# observed state against the requested value. Commands absent from these tables +# - true toggles, relative steps, and ack-only actions whose outcome cannot be +# derived from the request - have no plan and raise the unconfirmed timeout. VerifyPlan = tuple[str, Callable[[Any], bool]] @@ -279,29 +280,34 @@ def _plan_auto_conditioning(action: VehicleAction) -> VerifyPlan | None: class VehicleBluetooth(Commands[BluetoothParentT], Generic[BluetoothParentT]): """Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing. - Callers can catch failures from this class with a single ``TeslaFleetError``: - connect/notify/write transport failures surface as - ``BluetoothTransportError`` and a response-wait timeout as - ``BluetoothTimeout``, both ``TeslaFleetError`` subclasses with the original - transport exception chained as their cause. - - A ``BluetoothTimeout`` raised by a *mutating* command (RKE/closure - actions, HVAC/media/charging commands, ``wake_up``) is inconclusive, not - a failure - the vehicle can execute the command without its ack reaching - the client. Callers must snapshot state before acting and verify the - outcome with a follow-up state read after any timeout, and must never - blind-retry a non-idempotent command (toggles, volume steps, schedule - add/remove) on a timeout alone. The inherited WAIT/fault retry - (``Commands._command``) can also re-send an already-executed command. + Callers can catch failures from this class with a single ``TeslaFleetError``, + but three distinct outcomes hide behind that base: a connect/notify/write + transport failure before any command reached the vehicle + (``BluetoothTransportError``), an ack-wait timeout for a *mutating* + command that was already written to the vehicle (``BluetoothUnconfirmedCommand``), + and a plain response-wait timeout for anything else, e.g. a state read + (``BluetoothTimeout``). Each preserves the underlying failure in its cause + chain when one exists. + + A ``BluetoothUnconfirmedCommand`` (RKE/closure actions, HVAC/media/charging + commands, ``wake_up``) means the vehicle can have executed the command + without its ack reaching the client - it is unconfirmed, not failed. + Callers must snapshot state before acting and verify the outcome with a + follow-up state read after any such timeout, and must never blind-retry a + non-idempotent command (toggles, volume steps, schedule add/remove) on a + timeout alone, nor replay it on a fallback transport. The inherited + WAIT/fault retry (``Commands._command``) can also re-send an + already-executed command. Because it subclasses ``BluetoothTimeout``, + existing ``except BluetoothTimeout`` handling still catches it. Pass ``verify_commands=True`` to resolve that ambiguity inside this class: on a timeout from a mutating command whose expected post-state is derivable from its arguments, the same held connection reads the mapped prover state and either returns a normal success result (the command executed) or - re-raises the ``BluetoothTimeout`` (it did not). Commands whose outcome - cannot be derived or read - true toggles, relative steps, and ack-only - actions - re-raise the timeout unchanged, exactly as with verification off - (the default). + re-raises the ``BluetoothUnconfirmedCommand`` (it could not be confirmed). + Commands whose outcome cannot be derived or read - true toggles, relative + steps, and ack-only actions - raise the unconfirmed timeout, exactly as with + verification off (the default). ``keepalive_interval`` (default ~20s, ``None``/``0`` disables) keeps an otherwise idle held connection from dropping: after that many seconds with @@ -748,17 +754,32 @@ async def _command( domain, command, attempt, expects_data=expects_data ) + async def _ensure_handshake(self, domain: Domain) -> None: + if not self._sessions[domain].ready: + await self._handshake(domain) + if not self._sessions[domain].ready: + raise BluetoothTimeout() + async def _sendVehicleSecurity(self, command: UnsignedMessage) -> dict[str, Any]: - """Send a VCSEC actuation, optionally resolving a lost-ack timeout by state.""" - if not self.verify_commands: - return await super()._sendVehicleSecurity(command) + """Send a VCSEC actuation. + + A lost ack after the write reached the vehicle is unconfirmed, not + failed, so a timed-out wait raises ``BluetoothUnconfirmedCommand`` + rather than plain ``BluetoothTimeout`` - see that exception's + docstring. With ``verify_commands`` on, that ambiguity is resolved by + reading back state before it can reach the caller. + """ + await self._ensure_handshake(Domain.DOMAIN_VEHICLE_SECURITY) try: return await super()._sendVehicleSecurity(command) except BluetoothTimeout as timeout: + unconfirmed = BluetoothUnconfirmedCommand(timeout.data, timeout.status) + if not self.verify_commands: + raise unconfirmed from timeout name = vcsec_command_name(command) try: result = await self._resolve_timeout( - _vcsec_verify_plan(command), timeout + _vcsec_verify_plan(command), unconfirmed ) except BluetoothTimeout: LOGGER.debug( @@ -775,20 +796,29 @@ async def _sendVehicleSecurity(self, command: UnsignedMessage) -> dict[str, Any] ) return result - async def _sendInfotainment(self, command: Action) -> dict[str, Any]: - """Send an infotainment command, optionally resolving a lost-ack timeout by state.""" - if not self.verify_commands: - return await super()._sendInfotainment(command) + async def _sendInfotainment( + self, command: Action, *, mutating: bool = True + ) -> dict[str, Any]: + """Send an infotainment command. + + Same unconfirmed-ack semantics as ``_sendVehicleSecurity`` - see there. + """ + await self._ensure_handshake(Domain.DOMAIN_INFOTAINMENT) try: return await super()._sendInfotainment(command) except BluetoothTimeout as timeout: + if not mutating: + raise + unconfirmed = BluetoothUnconfirmedCommand(timeout.data, timeout.status) + if not self.verify_commands: + raise unconfirmed from timeout name = infotainment_command_name(command) resolver = _INFOTAINMENT_VERIFY_PLANS.get( command.vehicleAction.WhichOneof("vehicle_action_msg") ) plan = resolver(command.vehicleAction) if resolver else None try: - result = await self._resolve_timeout(plan, timeout) + result = await self._resolve_timeout(plan, unconfirmed) except BluetoothTimeout: LOGGER.debug( "command=%s transport=%s verify_commands=unresolved", @@ -811,7 +841,7 @@ async def _resolve_timeout( The prover read rides the same held connection. An INFO-domain prover needs the vehicle awake; if the read cannot complete (e.g. the car is - asleep) it surfaces a ``TeslaFleetError`` and the original timeout is + asleep) it surfaces a ``TeslaFleetError`` and the unconfirmed timeout is re-raised rather than waking the car just to verify. """ if plan is None: @@ -956,13 +986,13 @@ def _raise_for_whitelist_reply(self, resp: RoutableMessage) -> None: async def wake_up(self): """Wake up the vehicle security computer. - A ``BluetoothTimeout`` from this command can be a false negative even - when the vehicle wakes successfully, so callers should treat wake as - best-effort and confirm readiness with a retried INFO-domain read. - The infotainment computer may still need a short delay before it can - complete signed-command handshakes, so callers that issue INFO-domain - reads immediately after waking should retry ``BluetoothTimeout`` with - backoff. + A ``BluetoothUnconfirmedCommand`` from this command can be a false + negative even when the vehicle wakes successfully, so callers should + treat wake as best-effort and confirm readiness with a retried + INFO-domain read. The infotainment computer may still need a short + delay before it can complete signed-command handshakes, so callers that + issue INFO-domain reads immediately after waking should retry + ``BluetoothTimeout`` with backoff. """ return await self._sendVehicleSecurity( UnsignedMessage(RKEAction=RKEAction_E.RKE_ACTION_WAKE_VEHICLE) diff --git a/tesla_fleet_api/tesla/vehicle/commands.py b/tesla_fleet_api/tesla/vehicle/commands.py index aa2d624..9fb483f 100644 --- a/tesla_fleet_api/tesla/vehicle/commands.py +++ b/tesla_fleet_api/tesla/vehicle/commands.py @@ -748,7 +748,9 @@ async def _getVehicleSecurity(self, command: UnsignedMessage) -> VehicleStatus: _log_command_result(name, self._transport_name, reply) return reply["response"] - async def _sendInfotainment(self, command: Action) -> dict[str, Any]: + async def _sendInfotainment( + self, command: Action, *, mutating: bool = True + ) -> dict[str, Any]: """Sign and send a message to Infotainment computer.""" name = infotainment_command_name(command) try: @@ -801,7 +803,8 @@ async def _handshake(self, domain: Domain) -> bool: async def ping(self) -> dict[str, Any]: """Ping the vehicle.""" return await self._sendInfotainment( - Action(vehicleAction=VehicleAction(ping=Ping(ping_id=0))) + Action(vehicleAction=VehicleAction(ping=Ping(ping_id=0))), + mutating=False, ) async def actuate_trunk(self, which_trunk: Trunk | str) -> dict[str, Any]: @@ -1735,7 +1738,8 @@ async def nearby_charging_sites( action.include_meta_data = detail return await self._sendInfotainment( - Action(vehicleAction=VehicleAction(getNearbyChargingSites=action)) + Action(vehicleAction=VehicleAction(getNearbyChargingSites=action)), + mutating=False, ) # options doesnt require signing @@ -2267,7 +2271,8 @@ async def get_charge_on_solar(self) -> dict[str, Any]: vehicleAction=VehicleAction( getChargeOnSolarFeatureRequest=GetChargeOnSolarFeatureRequest() ) - ) + ), + mutating=False, ) # Group 10: Navigation diff --git a/tesla_fleet_api/tesla/vehicle/vehicles.py b/tesla_fleet_api/tesla/vehicle/vehicles.py index aaed079..16f0746 100644 --- a/tesla_fleet_api/tesla/vehicle/vehicles.py +++ b/tesla_fleet_api/tesla/vehicle/vehicles.py @@ -52,7 +52,8 @@ def createBluetooth( """Creates a bluetooth vehicle that uses command protocol. Set ``verify_commands`` to confirm supported mutating BLE command - timeouts by reading the resulting state before surfacing the timeout. + timeouts by reading the resulting state before surfacing an unconfirmed + command timeout. ``keepalive_interval`` seconds of GATT idleness triggers a passive read to hold the link open (``None``/``0`` disables). """ @@ -94,7 +95,8 @@ def create( """Creates a bluetooth vehicle that uses command protocol. Set ``verify_commands`` to confirm supported mutating BLE command - timeouts by reading the resulting state before surfacing the timeout. + timeouts by reading the resulting state before surfacing an unconfirmed + command timeout. ``keepalive_interval`` seconds of GATT idleness triggers a passive read to hold the link open (``None``/``0`` disables). """ @@ -113,7 +115,8 @@ def createBluetooth( """Creates a bluetooth vehicle that uses command protocol. Set ``verify_commands`` to confirm supported mutating BLE command - timeouts by reading the resulting state before surfacing the timeout. + timeouts by reading the resulting state before surfacing an unconfirmed + command timeout. ``keepalive_interval`` seconds of GATT idleness triggers a passive read to hold the link open (``None``/``0`` disables). """ diff --git a/tests/test_ble_unconfirmed_command.py b/tests/test_ble_unconfirmed_command.py new file mode 100644 index 0000000..b9bfbf0 --- /dev/null +++ b/tests/test_ble_unconfirmed_command.py @@ -0,0 +1,162 @@ +"""Tests for the pre-write vs. post-write BLE timeout distinction. + +A GATT-write failure (connect/notify/write) before a command reaches the +vehicle is a genuine transport failure and keeps raising +``BluetoothTransportError``. An ack-wait timeout for a *mutating* command +already written to the vehicle is unconfirmed, not failed - the vehicle may +have executed it anyway (lock/unlock have both been observed to execute +despite a lost ack) - and now raises ``BluetoothUnconfirmedCommand`` instead +of plain ``BluetoothTimeout``, so a caller such as ``Router`` can tell the two +apart and avoid blind-retrying or failing over to another transport. A read +(no mutation, nothing to have "executed") keeps raising plain +``BluetoothTimeout`` on the same kind of ack-wait timeout. + +Uses the same mocked-``_send`` harness as ``test_ble_command_verification.py``. +""" + +from __future__ import annotations + +from typing import Any, cast + +from tesla_fleet_api.exceptions import ( + BluetoothTimeout, + BluetoothTransportError, + BluetoothUnconfirmedCommand, +) +from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import Domain +from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import ( + Destination, + RoutableMessage, +) +from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( + VehicleLockState_E, + VehicleStatus, +) + +from ble_mocked_transport import ( + MockedBleTransportTestCase, + vcsec_vehicle_status_reply, +) + + +class MutatingCommandTimeoutTests(MockedBleTransportTestCase): + """A lost ack for a mutating command raises the unconfirmed subclass.""" + + async def test_vcsec_actuation_timeout_raises_unconfirmed(self) -> None: + vehicle, send = self.make_vehicle() + send.side_effect = BluetoothTimeout() + + with self.assertRaises(BluetoothUnconfirmedCommand): + await vehicle.door_lock() + + async def test_infotainment_action_timeout_raises_unconfirmed(self) -> None: + vehicle, send = self.make_vehicle() + send.side_effect = BluetoothTimeout() + + with self.assertRaises(BluetoothUnconfirmedCommand): + await vehicle.honk_horn() + + async def test_non_mutating_infotainment_timeout_raises_plain_timeout( + self, + ) -> None: + vehicle, send = self.make_vehicle() + send.side_effect = BluetoothTimeout() + + with self.assertRaises(BluetoothTimeout) as ctx: + await vehicle.ping() + + self.assertNotIsInstance(ctx.exception, BluetoothUnconfirmedCommand) + + async def test_unconfirmed_is_still_a_bluetooth_timeout(self) -> None: + # Existing `except BluetoothTimeout` handling (verify_commands, + # pair()'s fast path) must keep working unchanged. + vehicle, send = self.make_vehicle() + send.side_effect = BluetoothTimeout() + + with self.assertRaises(BluetoothTimeout): + await vehicle.door_lock() + + async def test_unconfirmed_chains_the_original_timeout(self) -> None: + vehicle, send = self.make_vehicle() + original = BluetoothTimeout() + send.side_effect = original + + with self.assertRaises(BluetoothUnconfirmedCommand) as ctx: + await vehicle.door_lock() + + self.assertIs(ctx.exception.__cause__, original) + + async def test_verify_commands_unresolved_raises_unconfirmed(self) -> None: + # With verify_commands on, a prover read that disagrees still leaves + # the caller with an unconfirmed (not confirmed-failed) outcome. + vehicle, send = self.make_vehicle(verify_commands=True) + send.side_effect = [ + BluetoothTimeout(), + vcsec_vehicle_status_reply( + VehicleStatus( + vehicleLockState=VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED + ) + ), + ] + + with self.assertRaises(BluetoothUnconfirmedCommand): + await vehicle.door_lock() + + async def test_handshake_timeout_raises_plain_bluetooth_timeout(self) -> None: + vehicle, send = self.make_vehicle() + sessions = cast("dict[int, Any]", getattr(vehicle, "_sessions")) + sessions[Domain.DOMAIN_VEHICLE_SECURITY].epoch = None + send.side_effect = BluetoothTimeout() + + with self.assertRaises(BluetoothTimeout) as ctx: + await vehicle.door_lock() + + self.assertNotIsInstance(ctx.exception, BluetoothUnconfirmedCommand) + + async def test_ack_only_handshake_raises_plain_bluetooth_timeout(self) -> None: + vehicle, send = self.make_vehicle() + sessions = cast("dict[int, Any]", getattr(vehicle, "_sessions")) + sessions[Domain.DOMAIN_VEHICLE_SECURITY].epoch = None + + async def ack_only( + msg: RoutableMessage, _requires: str, **_kwargs: Any + ) -> RoutableMessage: + return RoutableMessage( + from_destination=Destination(domain=Domain.DOMAIN_VEHICLE_SECURITY), + request_uuid=msg.uuid, + ) + + send.side_effect = ack_only + + with self.assertRaises(BluetoothTimeout) as ctx: + await vehicle.door_lock() + + self.assertNotIsInstance(ctx.exception, BluetoothUnconfirmedCommand) + send.assert_awaited_once() + + +class ReadTimeoutTests(MockedBleTransportTestCase): + """A lost response for a read (no mutation) keeps raising plain BluetoothTimeout.""" + + async def test_read_timeout_raises_plain_bluetooth_timeout(self) -> None: + vehicle, send = self.make_vehicle() + send.side_effect = BluetoothTimeout() + + with self.assertRaises(BluetoothTimeout) as ctx: + await vehicle.charge_state() + + self.assertNotIsInstance(ctx.exception, BluetoothUnconfirmedCommand) + + +class TransportFailureTests(MockedBleTransportTestCase): + """A pre-write transport failure is unaffected and stays distinguishable.""" + + async def test_write_failure_raises_transport_error_not_unconfirmed(self) -> None: + vehicle, send = self.make_vehicle() + send.side_effect = BluetoothTransportError() + + with self.assertRaises(BluetoothTransportError) as ctx: + await vehicle.door_lock() + + self.assertNotIsInstance(ctx.exception, BluetoothTimeout) + self.assertNotIsInstance(ctx.exception, BluetoothUnconfirmedCommand) diff --git a/tests/test_router.py b/tests/test_router.py index 8c253b4..77c97b7 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -8,7 +8,7 @@ import asyncio from unittest import IsolatedAsyncioTestCase -from tesla_fleet_api.exceptions import BluetoothTimeout +from tesla_fleet_api.exceptions import BluetoothTimeout, BluetoothUnconfirmedCommand from tesla_fleet_api.router import ( EnergySiteRouter, Router, @@ -107,6 +107,20 @@ async def test_primary_raises_tesla_fleet_error_falls_back(self): self.assertEqual(primary.shared_calls, 1) self.assertEqual(fallback.shared_calls, 1) + async def test_primary_raises_unconfirmed_command_does_not_fall_back(self): + # Unlike a plain BluetoothTimeout, an unconfirmed mutating command may + # have already executed on the primary - failing over would risk + # double-executing it, so it must propagate instead of falling back. + primary = _FakePrimary(exc=BluetoothUnconfirmedCommand()) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + with self.assertRaises(BluetoothUnconfirmedCommand): + await router.shared(10) + + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 0) + async def test_primary_raises_cancelled_error_propagates(self): # CancelledError is a BaseException but not a TeslaFleetError; it must # propagate and never trigger fallback. @@ -219,15 +233,25 @@ class _FakeBackend: backend can be skipped entirely by the router. """ - def __init__(self, tag: str, *, fail: bool = False, has: bool = True): + def __init__( + self, + tag: str, + *, + fail: bool = False, + has: bool = True, + exc: BaseException | None = None, + ): self.tag = tag self.fail = fail + self.exc = exc self.calls = 0 if has: self.cmd = self._cmd # type: ignore[assignment] async def _cmd(self, value: int) -> str: self.calls += 1 + if self.exc is not None: + raise self.exc if self.fail: raise ConnectionError(f"{self.tag} failed") return f"{self.tag}:{value}" @@ -253,6 +277,19 @@ async def test_skips_backend_missing_the_method(self): self.assertEqual(await router.cmd(2), "c:2") self.assertEqual((a.calls, b.calls, c.calls), (1, 0, 1)) + async def test_unconfirmed_command_from_middle_backend_stops_chain(self): + # b's mutating command may have already executed; c must never be + # tried, since that would risk double-executing it there too. + a = _FakeBackend("a", fail=True) + b = _FakeBackend("b", exc=BluetoothUnconfirmedCommand()) + c = _FakeBackend("c") + router = Router(a, b, c) + + with self.assertRaises(BluetoothUnconfirmedCommand): + await router.cmd(9) + + self.assertEqual((a.calls, b.calls, c.calls), (1, 1, 0)) + async def test_fails_over_through_multiple_backends_to_last(self): a = _FakeBackend("a", fail=True) b = _FakeBackend("b", fail=True) diff --git a/uv.lock b/uv.lock index 25a5d28..c6db893 100644 --- a/uv.lock +++ b/uv.lock @@ -728,7 +728,7 @@ wheels = [ [[package]] name = "tesla-fleet-api" -version = "1.6.3" +version = "1.6.4" source = { editable = "." } dependencies = [ { name = "aiofiles" },