diff --git a/AGENTS.md b/AGENTS.md index bcd0784..fd63907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, keepalive_interval=..., optimistic=False, raise_unconfirmed=True)` → `VehicleBluetooth` +- `createBluetooth(vin, confirmation="ack", keepalive_interval=..., raise_unconfirmed=False, *, verify_commands=None, optimistic=None)` → `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). @@ -127,12 +127,12 @@ 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, 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 BLE mutating-command confirmation ladder is one `confirmation` enum + one `raise_unconfirmed` bool**: `VehicleBluetooth.confirmation` (`"optimistic" | "ack" | "verify"`, default `"ack"`; threaded through `Vehicles`/`VehiclesBluetooth.create*`) picks how many of write → ack-or-broadcast wait → state-read confirmation run; `raise_unconfirmed` (default `False`) picks what happens when the ladder still can't tell. `"optimistic"` short-circuits `_sendVehicleSecurity`/`_sendInfotainment` (`bluetooth.py`) to `_send_optimistic()`, which signs and writes but never waits for any reply - only `BluetoothTransportError` can raise, and nothing else in the ladder is consulted. `"verify"` adds a post-timeout state-read rung: on an unresolved ack/broadcast wait, `_resolve_timeout()` reads the mapped prover state (`_vcsec_verify_plan`/`_INFOTAINMENT_VERIFY_PLANS` in `bluetooth.py`; only clearly-derivable absolute commands are covered - lock/unlock, `set_charge_limit`, `set_charging_amps`, `adjust_volume` absolute, `set_temps`, `auto_conditioning_start/stop`) and returns success on a match, raises `BluetoothCommandFailed` on a proven mismatch, or returns `None` (still unresolved) if the read itself couldn't complete - `None` falls through to `raise_unconfirmed`. Commands with no plan (true toggles, relative steps, ack-only actions) always fall through regardless of `confirmation`. The legacy `optimistic`/`verify_commands` boolean surface is deprecated: both warn (`DeprecationWarning`) and map onto `confirmation` (a positional bool in the `confirmation` slot is treated as old `verify_commands`; dominance order preserved - `optimistic=True` wins if both are set), and remain as read-only properties (`confirmation == "optimistic"`/`"verify"`) for existing readers. See `docs/bluetooth_vehicles.md` for the user-facing table and defaults. +- **Broadcast-as-confirmation races the ack wait for lock/unlock**: the vehicle keeps emitting unsolicited VCSEC status broadcasts on the same notification subscription even when it emits no addressed ack for a lock/unlock actuation - live-verified at scale (see the timeout-rate study referenced from `docs/bluetooth_vehicles.md`). `_send`'s `confirm_broadcast` param (threaded through `Commands._command`/`_sendVehicleSecurity`, ignored by the Fleet-signed transport) arms a per-domain watcher in `_on_message` (`_broadcast_watchers`, `bluetooth.py`) that decodes broadcast frames via `_decode_vcsec_status` and races them against the addressed-reply wait in `_await_response_or_broadcast`; first to satisfy the plan's predicate wins, and only the addressed-reply path can raise a car-side rejection. A mismatching broadcast doesn't fail fast (it's appended to `mismatches`, not resolved) since a later broadcast in the same window could still confirm success - but if the whole window elapses with a mismatch as the last word and nothing else confirming, `_await_response_or_broadcast` raises `BluetoothCommandFailed` instead of the ambiguous timeout. This reuses the exact same `_vcsec_verify_plan` predicate as the `"verify"` rung above (one source of truth), applied to a broadcast's decoded `VehicleStatus` instead of a follow-up read; it currently covers only lock/unlock, the one VCSEC actuation with an observed status broadcast. Low-level race tests drive the real (unmocked) `_send` state machine directly - see `tests/test_ble_broadcast_confirmation.py`. +- **`BluetoothUnconfirmedCommand` vs `BluetoothCommandFailed` (`exceptions.py`), and how `Router` treats each**: `_sendVehicleSecurity`/`_sendInfotainment` (`bluetooth.py`, the mutating-command seam, unconditionally) wrap a caught `BluetoothTimeout` into `BluetoothUnconfirmedCommand` when the ladder is genuinely unresolved - the write succeeded and the vehicle may have executed the command despite the lost ack/broadcast. With default `raise_unconfirmed=False` that unresolved outcome returns best-effort success; with `raise_unconfirmed=True` the `BluetoothUnconfirmedCommand` reaches the caller. `BluetoothCommandFailed` is the other, distinct outcome: a state check (the `"verify"` rung's read, or a mismatching broadcast still standing at window-end) actively *proved* the command did not apply - it deliberately does **not** subclass `BluetoothTimeout`/`BluetoothUnconfirmedCommand`. `Router._dispatch` (`router/base.py`) special-cases only `BluetoothUnconfirmedCommand` to skip its normal per-command failover and re-raise immediately - replaying an already-possibly-executed command risks double-execution. `BluetoothCommandFailed` carries no such risk (the command is proven not to have applied), so it falls through `Router`'s ordinary `except (Exception, TeslaFleetError)` clause and fails over like any other error - no `Router` code change was needed for this, only the exception type's placement outside the `BluetoothTimeout` hierarchy. 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`. +- **`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 an unresolved wake remains only an inconclusive wake signal, not command failure (`BluetoothUnconfirmedCommand` when `raise_unconfirmed=True`, best-effort success by default). 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 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. +- **`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 reaches the unresolved `raise_unconfirmed` outcome 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 e5771c7..09ad9ea 100644 --- a/README.md +++ b/README.md @@ -174,13 +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__`. 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. 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 +transport exception chained as `__cause__`. Mutating BLE commands use a +confirmation ladder controlled by `confirmation` (`"ack"` by default) and +`raise_unconfirmed` (`False` by default): an inconclusive lost acknowledgement +resolves as best-effort success unless you opt in to +`BluetoothUnconfirmedCommand`, while a command proven not to have applied raises +`BluetoothCommandFailed`. See [Bluetooth for Vehicles](docs/bluetooth_vehicles.md) +for the full 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. @@ -201,7 +201,7 @@ 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("", verify_commands=True) + primary = tesla_bluetooth.vehicles.create("", confirmation="verify") # Secondary (fallback): Teslemetry cloud teslemetry = Teslemetry(access_token="", session=session) @@ -234,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, 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. +> **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 `confirmation="verify"` to resolve supported mutating command timeouts by state before they reach the router, and set `raise_unconfirmed=True` when callers must see still-ambiguous outcomes instead of the default best-effort success; 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 1d53390..075cb44 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -119,10 +119,11 @@ async def main(): asyncio.run(main()) ``` -`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 +`wake_up()` is best-effort over BLE: with `raise_unconfirmed=True`, 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 @@ -139,16 +140,22 @@ 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. -By default, a response-wait timeout for a *mutating* command (RKE/closure -actions, HVAC/media/charging commands, `wake_up()`) raises +A response-wait timeout for a *mutating* command (RKE/closure actions, +HVAC/media/charging commands, `wake_up()`) that stays genuinely unresolved is +handled by the `raise_unconfirmed` knob: the default best-effort mode returns a +success-shaped response, while `raise_unconfirmed=True` raises `BluetoothUnconfirmedCommand` instead of plain `BluetoothTimeout` - see -"Mutating Command Timeouts" below for the `verify_commands`, `optimistic`, and -`raise_unconfirmed` knobs that can change that outcome. A response-wait timeout -for anything else (a state read) still raises plain `BluetoothTimeout`. +"Mutating Command Timeouts" below for the `confirmation` and +`raise_unconfirmed` knobs that shape that outcome. 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." +have executed" apart from "nothing happened." A mutating command *proven* not +to have taken effect - a `confirmation="verify"` read or a mismatching +broadcast that stood at the end of the wait window - raises +`BluetoothCommandFailed` instead, a distinct type a fallback router fails over +on normally (see below). 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 @@ -159,20 +166,19 @@ the next response, but it does not change command acknowledgement timeouts. ## Mutating Command Timeouts A `BluetoothUnconfirmedCommand` from a mutating BLE command is unconfirmed, not -proof that the command failed. The vehicle can apply the command even when its +proof that the command failed. It is raised only when `raise_unconfirmed=True`; +with the default `False`, the same still-ambiguous outcome returns a +best-effort success. 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. +both been observed to execute despite this exception. Never blind-retry the +same command, and never replay it on a fallback transport (e.g. a +BLE-primary/cloud fallback router) on this exception alone - see "The +confirmation ladder" below for how the library resolves this for you, and +"Skipping the wait" for the constructor knobs that control how hard it tries. 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 -acknowledgement is lost, they use a shorter response timeout than reads; the -timeout is still inconclusive and should be handled with the same verify-by-state -pattern. +their terminal acknowledgement arrives because no data frame follows it. If +that acknowledgement is lost, they use a shorter response timeout than reads. Do not blind-retry non-idempotent commands, such as media toggles, volume steps, or schedule add/remove operations, on timeout alone. The signed-command retry @@ -180,29 +186,40 @@ inside the library can also re-send an identical command after a WAIT status or epoch/token fault, so verify by absolute state rather than by counting command attempts. -### Opt-in state verification (`verify_commands`) - -Construct a `VehicleBluetooth` with `verify_commands=True` (also accepted by -`vehicles.create(...)` / `vehicles.createBluetooth(...)`, default off) to have -the class resolve that ambiguity for you. Verification runs only on a timeout, -so the normal path - which returns as soon as the terminal ack arrives - keeps -its speed and pays no extra read; the prover read happens only in the ambiguous -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 raises -`BluetoothUnconfirmedCommand` when a completed read does not match. The read +### The confirmation ladder + +A mutating BLE command tries to confirm itself in up to four steps: write the +GATT characteristic, wait for the addressed ack (racing a matching state +broadcast for lock/unlock), fall back to a state read, then decide the final +outcome. `confirmation` (constructor/factory arg, default `"ack"`) picks how +many of those steps run; `raise_unconfirmed` (default `False`) picks what the +last step does when nothing above it settled the question. + +**Broadcast confirmation (lock/unlock, `"ack"` and `"verify"`).** The vehicle +keeps emitting unsolicited VCSEC status broadcasts on the same notification +subscription even when it emits no addressed ack for a lock/unlock actuation. +`VehicleBluetooth` races the ack wait against a matching broadcast and returns +success as soon as either confirms - so most ack losses for lock/unlock now +resolve to a real confirmed success well before the ack-wait ceiling, with no +extra read. A broadcast showing a different state does not fail fast (a later +broadcast in the same window could still confirm success), but if the whole +window elapses with such a mismatch as the last word and nothing else +confirming, that is proof the command did not apply and raises +`BluetoothCommandFailed` rather than the ambiguous timeout. + +**State-read verification (`confirmation="verify"`).** When a mutating command +still hasn't resolved after the ack/broadcast wait 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 +raises `BluetoothCommandFailed` when a completed read does not match. The read rides the existing connection and never wakes the vehicle; if an infotainment prover cannot be read because the car is asleep, the outcome stays unresolved -and falls through to the `raise_unconfirmed` setting. +and falls through to `raise_unconfirmed`. This runs only under +`confirmation="verify"` and only on a still-unresolved wait, so the normal +path - which returns as soon as something confirms - keeps its speed. -With the default `raise_unconfirmed=True`, commands whose outcome cannot be -derived or read - true toggles, relative volume steps, and ack-only actions such -as `flash_lights()` or `trigger_homelink()` - raise -`BluetoothUnconfirmedCommand`, exactly as with verification off. Currently -verified commands and their provers: +Verified commands and their provers: | Command | Prover | Confirmed when | | --- | --- | --- | @@ -214,51 +231,52 @@ verified commands and their provers: | `auto_conditioning_start()` / `auto_conditioning_stop()` | `climate_state()` | `is_climate_on` matches | The VCSEC lock prover is readable while the vehicle is asleep; the infotainment -provers require the vehicle awake. This feature does not change behavior for any -command not in the table, and it is inert unless `verify_commands=True`. - -### Skipping the wait (`optimistic`) and defaulting to success (`raise_unconfirmed`) - -The full confirmation ladder for a mutating BLE command is: write the GATT -characteristic, wait for the ack, resolve via `verify_commands` on a timeout, -then decide the final outcome. Two more constructor/factory knobs (default off -and default on, respectively - both preserve today's behavior unless set) -shape that last step: - -- `optimistic=True` returns success as soon as the GATT write is confirmed, - skipping the ack wait and `verify_commands` entirely for every mutating - command. This is pure speed mode: the caller owns any state verification it - wants afterward. Only a write/transport failure (`BluetoothTransportError`) - still raises. `ping()` (the one non-mutating infotainment send) is exempt - and always waits for its real reply. -- `raise_unconfirmed=False` changes what happens only when the ladder is - exhausted without a definite answer - `verify_commands` is off, has no plan - for this command, or its prover read itself could not complete (e.g. the - car is asleep). Instead of raising `BluetoothUnconfirmedCommand`, that - ambiguous case resolves as a best-effort success - (`{"response": {"result": True, "reason": ""}}`). A car-side rejection - carried in an ack, a `verify_commands` state mismatch (the prover read - completed and does not show the requested value), and write failures are - unchanged and always raise - this flag converts only the "could not - determine what happened" outcome. - -Commands with no `verify_commands` plan - true toggles, relative volume steps, -and ack-only actions such as `flash_lights()` or `trigger_homelink()` (every -command not in the table above) - have nothing to mismatch against, so under -`raise_unconfirmed=False` a timeout on any of them always resolves best-effort. -Treat those commands as best-effort by design; rely on `verify_commands`'s -table for a reliable, state-backed confirmation on the commands it covers. +provers require the vehicle awake. Broadcast confirmation covers only +`door_lock()`/`door_unlock()` - the only commands with an observed status +broadcast; every other command in the table is reliably confirmed only under +`confirmation="verify"`. Commands not in the table - true toggles, relative +volume steps, and ack-only actions such as `flash_lights()` or +`trigger_homelink()` - are the documented best-effort set: they have no +broadcast and no prover to confirm against, so a still-unresolved wait always +falls through to `raise_unconfirmed` regardless of `confirmation`. + +### Skipping the wait (`confirmation="optimistic"`) and defaulting to success (`raise_unconfirmed`) + +- `confirmation="optimistic"` returns success as soon as the GATT write is + confirmed, consulting nothing else for every mutating command. This is pure + speed mode: the caller owns any state verification it wants afterward. Only + a write/transport failure (`BluetoothTransportError`) still raises. `ping()` + (the one non-mutating infotainment send) is exempt and always waits for its + real reply. +- `raise_unconfirmed=True` changes what happens only when the whole ladder is + exhausted without a definite answer - the ack/broadcast wait timed out and, + under `confirmation="verify"`, the prover read itself could not complete + (e.g. the car is asleep). Instead of the default best-effort success + (`{"response": {"result": True, "reason": ""}}`), that ambiguous case raises + `BluetoothUnconfirmedCommand`. A car-side rejection carried in an ack, any + proven non-application (`BluetoothCommandFailed`), and write failures are + unaffected and always raise - this flag converts only the "could not + determine what happened" outcome. It is moot under + `confirmation="optimistic"`, which is never inconclusive. ```python vehicle = tesla_bluetooth.vehicles.create( - "", optimistic=True + "", confirmation="optimistic" ) -# or, to keep waiting for an ack but never raise on an inconclusive outcome: +# or, to confirm as hard as possible but still never raise on a genuinely +# inconclusive outcome: vehicle = tesla_bluetooth.vehicles.create( - "", verify_commands=True, raise_unconfirmed=False + "", confirmation="verify", raise_unconfirmed=False # raise_unconfirmed=False is also the default ) ``` +`verify_commands`/`optimistic` booleans are still accepted as deprecated +constructor/factory aliases for `confirmation="verify"` / +`confirmation="optimistic"` - passing either emits a `DeprecationWarning` and +maps onto `confirmation`. A positional boolean in the `confirmation` slot is +also treated as the old positional `verify_commands` value. Prefer +`confirmation` directly in new code. + ## Climate Commands Bluetooth vehicles support the same signed climate command methods as @@ -452,9 +470,10 @@ 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 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. +If a BLE command is still ambiguous after the write, re-read the relevant state +before assuming whether the command applied. A `BluetoothUnconfirmedCommand` +(when `raise_unconfirmed=True`) is not enough to prove either failure or +success. `remote_boombox(sound)` also uses the INFO-domain signed-command transport and plays through the vehicle external speaker. Use it only when someone is present @@ -484,12 +503,14 @@ command=mediaPlayAction transport=bluetooth result=error error=BluetoothUnconfir 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 +For BLE commands run with `confirmation="verify"`, a resolved state-read logs a second line with `verify_commands=resolved` and the confirmed result; an -unresolved timeout logs `verify_commands=unresolved` before the -`BluetoothUnconfirmedCommand` propagates. With `raise_unconfirmed=False`, an -exhausted ladder logs `raise_unconfirmed=False result=success (best-effort)` -instead of propagating that exception. `Router` additionally logs +unresolved read logs `verify_commands=unresolved` before the exception +propagates (`BluetoothCommandFailed` on a proven mismatch, +`BluetoothUnconfirmedCommand` if the read itself couldn't complete). With +`raise_unconfirmed=False` (the default), an exhausted ladder logs +`raise_unconfirmed=False result=success (best-effort)` instead of raising +`BluetoothUnconfirmedCommand`. `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 diff --git a/tesla_fleet_api/const.py b/tesla_fleet_api/const.py index 4dc5786..e90154d 100644 --- a/tesla_fleet_api/const.py +++ b/tesla_fleet_api/const.py @@ -8,6 +8,11 @@ Region = Literal["na", "eu", "cn"] +# BLE command-confirmation ladder depth: "optimistic" consults nothing (fire +# and forget), "ack" waits for an addressed ack or a matching state broadcast, +# "verify" additionally reads back state on an ack/broadcast timeout. +BluetoothConfirmation = Literal["optimistic", "ack", "verify"] + SERVERS: dict[Region, str] = { "na": "https://fleet-api.prd.na.vn.cloud.tesla.com", "eu": "https://fleet-api.prd.eu.vn.cloud.tesla.com", diff --git a/tesla_fleet_api/exceptions.py b/tesla_fleet_api/exceptions.py index 51e6484..2996e25 100644 --- a/tesla_fleet_api/exceptions.py +++ b/tesla_fleet_api/exceptions.py @@ -51,6 +51,25 @@ class BluetoothUnconfirmedCommand(BluetoothTimeout): ) +class BluetoothCommandFailed(TeslaFleetError): + """A mutating Bluetooth command was proven NOT to have taken effect. + + Unlike ``BluetoothUnconfirmedCommand`` (ack lost, outcome unknown), this + means a state check - a ``confirmation="verify"`` post-timeout read, or + the vehicle's own status broadcast still showing a different value once + the whole confirmation window elapsed - actively contradicted the + requested end state. That makes it safe to fail over: a router replaying the + command on a fallback transport is not at risk of double-executing an + already-applied command, because this one demonstrably did not apply. + Deliberately does NOT subclass ``BluetoothTimeout``/ + ``BluetoothUnconfirmedCommand`` so a fallback router's per-command + failover (which only special-cases the ambiguous case) treats it like any + other ordinary failure and tries the next backend. + """ + + message = "Bluetooth command was proven not to have taken effect." + + 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/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index bdeb986..3447d57 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -4,6 +4,7 @@ import hashlib import struct import time +import warnings from random import randbytes from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar @@ -15,9 +16,10 @@ from cryptography.hazmat.primitives.asymmetric import ec from google.protobuf.message import DecodeError -from tesla_fleet_api.const import LOGGER, BluetoothVehicleData +from tesla_fleet_api.const import BluetoothConfirmation, BluetoothVehicleData, LOGGER from tesla_fleet_api.exceptions import ( WHITELIST_OPERATION_STATUS, + BluetoothCommandFailed, BluetoothTimeout, BluetoothTransportError, BluetoothUnconfirmedCommand, @@ -101,6 +103,7 @@ from tesla_fleet_api.tesla.tesla import Tesla BluetoothParentT = TypeVar("BluetoothParentT", bound="Tesla") +_BroadcastWatcher = Callable[[RoutableMessage], None] def prependLength(message: bytes) -> bytearray: @@ -208,16 +211,34 @@ def discard_packet(self): # Optional post-timeout command verification. # # 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 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. +# executed it anyway. Under ``confirmation="verify"``, a timed-out mutation +# whose outcome can be derived from its own arguments is confirmed by reading +# the mapped state instead of falling through to the ambiguous unconfirmed +# outcome. 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 fall through to +# ``raise_unconfirmed``. VerifyPlan = tuple[str, Callable[[Any], bool]] +def _decode_vcsec_status(msg: RoutableMessage) -> VehicleStatus | None: + """Decode a broadcast frame's VCSEC status payload, if it carries one. + + Broadcasts are unsigned - they have no ``signature_data`` and need no + session decrypt, unlike an addressed reply to our own signed request. + """ + if not msg.HasField("protobuf_message_as_bytes"): + return None + try: + vcsec = FromVCSECMessage.FromString(msg.protobuf_message_as_bytes) + except DecodeError: + return None + if vcsec.HasField("vehicleStatus"): + return vcsec.vehicleStatus + return None + + def _vcsec_verify_plan(command: UnsignedMessage) -> VerifyPlan | None: """Derive a VCSEC verify plan (read ``vehicle_state``) from a lost actuation.""" if command.WhichOneof("sub_message") != "RKEAction": @@ -281,11 +302,13 @@ 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``, - but three distinct outcomes hide behind that base: a connect/notify/write + but 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 + command that was already written to the vehicle and whose outcome is + genuinely unresolved (``BluetoothUnconfirmedCommand``), a mutating command + *proven* not to have taken effect (``BluetoothCommandFailed``), 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. @@ -299,37 +322,61 @@ class VehicleBluetooth(Commands[BluetoothParentT], Generic[BluetoothParentT]): 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 ``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). - - Two more knobs shape that same ladder (write -> ack wait -> ``verify_commands`` - -> unconfirmed outcome): - - - ``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 - (``BluetoothTransportError``) still raises. This is pure speed mode - the - caller owns any state verification it wants afterward - and it overrides - ``verify_commands`` and ``raise_unconfirmed`` for every mutating send. - - ``raise_unconfirmed`` (default ``True``, current behavior): when ``False``, - an exhausted ladder - the ack wait expired and ``verify_commands`` is off, - has no plan for this command, or could not complete its read - resolves as - a best-effort success instead of raising ``BluetoothUnconfirmedCommand``. - A car-side rejection carried in an ack, a ``verify_commands`` state - mismatch (the read completed and does not show the requested value), and - write failures are unaffected and always raise - this flag only converts - the "could not determine what happened" outcome. Commands with no - ``verify_commands`` plan (true toggles, relative steps, and ack-only - actions - see the table in ``docs/bluetooth_vehicles.md``) are always - resolved best-effort under this flag, since there is nothing to mismatch - against. + ``BluetoothCommandFailed`` deliberately does *not* subclass either - it + means a state check actively contradicted the request, not that the + outcome is unknown, so replaying the command on a fallback transport is + safe and a router fails over on it normally (see below). + + ``confirmation`` sets how hard a mutating command tries to confirm itself + before returning; it is a single ladder-depth choice, not an independent + flag per rung: + + - ``"optimistic"``: return success as soon as the GATT write is confirmed, + consulting nothing else. Only a write/transport failure + (``BluetoothTransportError``) still raises. Pure speed mode - the caller + owns any state verification it wants afterward. + - ``"ack"`` (default): wait for the addressed ack. For a VCSEC actuation + with a derivable expected end state (currently lock/unlock only), the + wait also races a matching status broadcast against the ack: the vehicle + keeps emitting unsolicited VCSEC status broadcasts on the same + notification subscription even when it emits no addressed ack for the + actuation, so a broadcast already showing the requested state confirms + success without waiting out the ack timeout. A broadcast that shows a + different state does not fail fast - it may simply predate the vehicle + finishing the actuation, and a later broadcast in the same window could + still confirm success - but if the whole window elapses with such a + mismatch as the last word and nothing else confirming, that is + now-final proof the command did not apply, raising + ``BluetoothCommandFailed`` rather than the ambiguous timeout. An + addressed ack, if it arrives first, still wins and still raises a real + car-side rejection. A command with no derivable end state - true + toggles, relative steps, and ack-only actions - has no broadcast to + race and simply waits for the ack, falling through to + ``raise_unconfirmed`` on timeout. + - ``"verify"``: same as ``"ack"``, plus one more rung - an ack/broadcast + timeout for a command whose expected post-state is derivable from its + arguments reads the mapped prover state over the same held connection + and either returns a normal success result (the command executed), + raises ``BluetoothCommandFailed`` (the read proves it did not), or + falls through to ``raise_unconfirmed`` (the read itself could not be + attempted, e.g. an INFO-domain prover needs the car awake). Commands + with no derivable prover fall through to ``raise_unconfirmed`` exactly + as under ``"ack"``. + + Both rungs above derive their expected-end-state predicate from the same + per-command table (one source of truth), just applied to a broadcast frame + or a follow-up read respectively. + + ``raise_unconfirmed`` (default ``False``) is the orthogonal question of what + to do once a ladder genuinely cannot resolve - the ack/broadcast wait + (and, under ``"verify"``, the prover read) neither confirmed nor + contradicted the request. Default ``False`` resolves that case as a + best-effort success; ``True`` raises ``BluetoothUnconfirmedCommand`` + instead. It is moot under ``confirmation="optimistic"``, which is never + inconclusive. A car-side rejection carried in an ack, any proven + non-application (``BluetoothCommandFailed``), and write failures are + unaffected by this flag and always raise - it only converts the "could not + determine what happened" outcome. ``keepalive_interval`` (default ~20s, ``None``/``0`` disables) keeps an otherwise idle held connection from dropping: after that many seconds with @@ -344,13 +391,13 @@ class VehicleBluetooth(Commands[BluetoothParentT], Generic[BluetoothParentT]): """ ble_name: str - verify_commands: bool - optimistic: bool + confirmation: BluetoothConfirmation raise_unconfirmed: bool keepalive_interval: float | None device: BLEDevice | None = None client: BleakClient | None = None _queues: dict[Domain, asyncio.Queue[RoutableMessage]] + _broadcast_watchers: dict[Domain, Callable[[RoutableMessage], None]] _ekey: ec.EllipticCurvePublicKey _buffer: ReassemblingBuffer _auth_method = "aes" @@ -371,25 +418,73 @@ def __init__( vin: str, key: ec.EllipticCurvePrivateKey | None = None, device: BLEDevice | None = None, - verify_commands: bool = False, + confirmation: BluetoothConfirmation | bool = "ack", keepalive_interval: float | None = DEFAULT_KEEPALIVE_INTERVAL, - optimistic: bool = False, - raise_unconfirmed: bool = True, + optimistic: bool | None = None, + raise_unconfirmed: bool = False, + *, + verify_commands: bool | None = None, ): + """Initialize a BLE-connected vehicle. + + ``verify_commands`` and ``optimistic`` are deprecated aliases for + ``confirmation="verify"``/``confirmation="optimistic"`` respectively; + passing either emits a ``DeprecationWarning`` and maps onto + ``confirmation``, overriding any value passed there (a ``True`` + ``optimistic`` wins over a ``True`` ``verify_commands`` if both are + somehow passed, matching the old dominance order). + """ super().__init__(parent, vin, key) - self.verify_commands = verify_commands + if isinstance(confirmation, bool): + warnings.warn( + 'positional verify_commands is deprecated; pass confirmation="verify" instead.', + DeprecationWarning, + stacklevel=2, + ) + confirmation = "verify" if confirmation else "ack" + if verify_commands is not None: + warnings.warn( + 'verify_commands is deprecated; pass confirmation="verify" instead.', + DeprecationWarning, + stacklevel=2, + ) + if verify_commands: + confirmation = "verify" + if optimistic is not None: + warnings.warn( + 'optimistic is deprecated; pass confirmation="optimistic" instead.', + DeprecationWarning, + stacklevel=2, + ) + if optimistic: + confirmation = "optimistic" + if confirmation not in ("optimistic", "ack", "verify"): + raise ValueError( + 'confirmation must be one of "optimistic", "ack", or "verify"' + ) + self.confirmation = confirmation self.keepalive_interval = keepalive_interval - self.optimistic = optimistic self.raise_unconfirmed = raise_unconfirmed self.ble_name = "S" + hashlib.sha1(vin.encode("utf-8")).hexdigest()[:16] + "C" self._queues = { Domain.DOMAIN_VEHICLE_SECURITY: asyncio.Queue(), Domain.DOMAIN_INFOTAINMENT: asyncio.Queue(), } + self._broadcast_watchers = {} self.device = device self._connect_lock = asyncio.Lock() self._buffer = ReassemblingBuffer(self._on_message) + @property + def optimistic(self) -> bool: + """Deprecated read-only view of ``confirmation == "optimistic"``.""" + return self.confirmation == "optimistic" + + @property + def verify_commands(self) -> bool: + """Deprecated read-only view of ``confirmation == "verify"``.""" + return self.confirmation == "verify" + async def find_vehicle( self, name: str | None = None, @@ -542,6 +637,9 @@ def _on_message(self, msg: RoutableMessage) -> None: if msg.to_destination.routing_address != self._from_destination: LOGGER.debug("Ignoring broadcast message (not addressed to us)") + watcher = self._broadcast_watchers.get(msg.from_destination.domain) + if watcher is not None: + watcher(msg) return queue = self._queues.get(msg.from_destination.domain) @@ -565,6 +663,7 @@ async def _send( *, timeout: float | None = None, optimistic: bool = False, + confirm_broadcast: Callable[[VehicleStatus], bool] | None = None, ) -> RoutableMessage: """Serialize a message and send to the vehicle and wait for a response. @@ -572,7 +671,9 @@ async def _send( VCSEC actuation, no data frame follows), so the ack is returned as soon as it arrives and a shorter total timeout applies. ``optimistic`` skips waiting for any reply at all - once the GATT write is confirmed, an - empty ``RoutableMessage`` is returned immediately. + empty ``RoutableMessage`` is returned immediately. ``confirm_broadcast`` + races the addressed-reply wait against a matching unsolicited state + broadcast on the same domain, returning on whichever arrives first. """ if timeout is None: @@ -591,63 +692,195 @@ async def _send( await self.connect_if_needed() assert self.client is not None + write_complete = False + mismatches: list[VehicleStatus] = [] + broadcast_future: asyncio.Future[RoutableMessage] | None = None + broadcast_watcher: _BroadcastWatcher | None = None + if confirm_broadcast is not None and not optimistic: + broadcast_future, broadcast_watcher = self._arm_broadcast_confirmation( + domain, confirm_broadcast, mismatches, lambda: write_complete + ) try: await self.client.write_gatt_char(WRITE_UUID, payload, True) + write_complete = True self._last_activity = time.monotonic() # bleak-esphome converts an aioesphomeapi write timeout into a # builtin TimeoutError, not a BleakError, so catch both to keep the # GATT-write transport failure within TeslaFleetError. except (BleakError, TimeoutError) as e: + self._disarm_broadcast_confirmation(domain, broadcast_watcher) raise BluetoothTransportError from e if optimistic: return RoutableMessage() - # Process the response - try: - async with asyncio.timeout(timeout): - LOGGER.debug(f"Waiting for response with {requires}") - while True: - resp = await self._queues[domain].get() - LOGGER.debug(f"Received message {resp}") - - self.validate_msg(resp) + if confirm_broadcast is None: + return await self._await_response( + domain, msg, requires, expects_data, timeout + ) + return await self._await_response_or_broadcast( + domain, + msg, + requires, + expects_data, + timeout, + broadcast_future, + broadcast_watcher, + mismatches, + ) - if resp.HasField(requires): + async def _await_response( + self, + domain: Domain, + msg: RoutableMessage, + requires: str, + expects_data: bool, + timeout: float, + ) -> RoutableMessage: + """Wait for the addressed reply carrying ``requires`` or our ack.""" + try: + async with asyncio.timeout(timeout): + LOGGER.debug(f"Waiting for response with {requires}") + while True: + resp = await self._queues[domain].get() + LOGGER.debug(f"Received message {resp}") + + self.validate_msg(resp) + + if resp.HasField(requires): + return resp + + # ACK response: has our request_uuid but not the required field. + # Some commands (e.g. RKE wake/lock) only return an ACK with no data. + # Wait briefly for a follow-up data response before returning the ACK. + if msg.uuid and resp.request_uuid == msg.uuid: + if not expects_data: + # A VCSEC actuation's ack is terminal; no data + # frame follows, so return without the wait. return resp - - # ACK response: has our request_uuid but not the required field. - # Some commands (e.g. RKE wake/lock) only return an ACK with no data. - # Wait briefly for a follow-up data response before returning the ACK. - if msg.uuid and resp.request_uuid == msg.uuid: - if not expects_data: - # A VCSEC actuation's ack is terminal; no data - # frame follows, so return without the wait. - return resp - LOGGER.debug( - "Received ACK for our request, waiting briefly for data follow-up" - ) - try: - async with asyncio.timeout(self._ack_followup_timeout): - while True: - resp2 = await self._queues[domain].get() - LOGGER.debug( - f"Received follow-up message {resp2}" - ) - self.validate_msg(resp2) - if resp2.HasField(requires): - return resp2 - except TimeoutError: - LOGGER.debug( - "No data follow-up, returning ACK response" - ) - return resp - LOGGER.debug( - f"Ignoring message without required field {requires}" + "Received ACK for our request, waiting briefly for data follow-up" ) - except TimeoutError as e: - raise BluetoothTimeout from e + try: + async with asyncio.timeout(self._ack_followup_timeout): + while True: + resp2 = await self._queues[domain].get() + LOGGER.debug(f"Received follow-up message {resp2}") + self.validate_msg(resp2) + if resp2.HasField(requires): + return resp2 + except TimeoutError: + LOGGER.debug("No data follow-up, returning ACK response") + return resp + + LOGGER.debug(f"Ignoring message without required field {requires}") + except TimeoutError as e: + raise BluetoothTimeout from e + + async def _await_response_or_broadcast( + self, + domain: Domain, + msg: RoutableMessage, + requires: str, + expects_data: bool, + timeout: float, + broadcast_future: asyncio.Future[RoutableMessage] | None, + broadcast_watcher: _BroadcastWatcher | None, + mismatches: list[VehicleStatus], + ) -> RoutableMessage: + """Race the addressed reply against a matching state broadcast. + + A broadcast that decodes but does not satisfy ``confirm_broadcast`` is + tracked, not treated as an immediate failure - it may simply predate + the vehicle finishing the actuation, and a later broadcast in the same + window could still confirm success. Only the addressed-reply path can + raise a rejection while the race is live. If the whole window elapses + with neither an ack nor a confirming broadcast, but at least one + post-write mismatching broadcast was observed, that mismatch is + now-final proof the command did not reach the requested state: + ``BluetoothCommandFailed`` is raised instead of the ambiguous + ``BluetoothTimeout``/``BluetoothUnconfirmedCommand``, so a fallback + transport can fail over safely instead of risking a double-apply on a + genuinely unresolved ack. + """ + response_task = asyncio.ensure_future( + self._await_response(domain, msg, requires, expects_data, timeout) + ) + assert broadcast_future is not None + broadcast_task = asyncio.ensure_future(broadcast_future) + try: + done, _ = await asyncio.wait( + {response_task, broadcast_task}, return_when=asyncio.FIRST_COMPLETED + ) + if response_task in done: + response_exc = response_task.exception() + if response_exc is None: + return response_task.result() + if ( + isinstance(response_exc, BluetoothTimeout) + and broadcast_task in done + ): + broadcast_exc = broadcast_task.exception() + if broadcast_exc is None: + return broadcast_task.result() + raise response_exc + return broadcast_task.result() + except BluetoothTimeout as timeout_exc: + if mismatches: + raise BluetoothCommandFailed( + timeout_exc.data, timeout_exc.status + ) from timeout_exc + raise + finally: + for task in (response_task, broadcast_task): + if not task.done(): + task.cancel() + await asyncio.gather(response_task, broadcast_task, return_exceptions=True) + self._disarm_broadcast_confirmation(domain, broadcast_watcher) + + def _arm_broadcast_confirmation( + self, + domain: Domain, + confirm_broadcast: Callable[[VehicleStatus], bool], + mismatches: list[VehicleStatus], + write_complete: Callable[[], bool], + ) -> tuple[asyncio.Future[RoutableMessage], _BroadcastWatcher]: + """Arm a watcher for broadcasts satisfying ``confirm_broadcast``. + + Live-verified: a VCSEC actuation's addressed ack can be lost while the + vehicle keeps emitting its status broadcast on the same notification + subscription, carrying the very state change the actuation caused. A + post-write broadcast that decodes but doesn't match is appended to + ``mismatches`` instead of resolving anything here - the caller only + treats it as proof of failure once the whole wait window elapses with + nothing else confirming. + """ + future: asyncio.Future[RoutableMessage] = ( + asyncio.get_running_loop().create_future() + ) + + def on_broadcast(broadcast: RoutableMessage) -> None: + if future.done(): + return + status = _decode_vcsec_status(broadcast) + if status is None: + return + if confirm_broadcast(status): + future.set_result(broadcast) + elif write_complete(): + mismatches.append(status) + + self._broadcast_watchers[domain] = on_broadcast + return future, on_broadcast + + def _disarm_broadcast_confirmation( + self, domain: Domain, broadcast_watcher: _BroadcastWatcher | None + ) -> None: + if ( + broadcast_watcher is not None + and self._broadcast_watchers.get(domain) is broadcast_watcher + ): + del self._broadcast_watchers[domain] # Group 12: VCSEC closures (Bluetooth-only for individual doors) @@ -781,11 +1014,16 @@ async def _command( command: bytes, attempt: int = 0, expects_data: bool = True, + confirm_broadcast: Callable[[VehicleStatus], bool] | None = None, ) -> dict[str, Any]: """Serialize a message and send to the signed command endpoint.""" await self.connect_if_needed() return await super()._command( - domain, command, attempt, expects_data=expects_data + domain, + command, + attempt, + expects_data=expects_data, + confirm_broadcast=confirm_broadcast, ) async def _ensure_handshake(self, domain: Domain) -> None: @@ -820,9 +1058,10 @@ def _unconfirmed_outcome( Reached only once nothing could determine what happened - verification is off, has no plan for this command, or its read could not complete. - A verify mismatch (the read completed and does not show the requested - value) is unambiguous negative evidence and is raised by the caller - directly, never routed through here. + Proven non-application - a verify-read mismatch, or a mismatching + broadcast still standing once the whole wait window elapsed - is + unambiguous negative evidence and raises ``BluetoothCommandFailed`` + directly from the caller, never routed through here. """ if self.raise_unconfirmed: raise unconfirmed from cause @@ -833,33 +1072,55 @@ def _unconfirmed_outcome( ) return {"response": {"result": True, "reason": ""}} - async def _sendVehicleSecurity(self, command: UnsignedMessage) -> dict[str, Any]: + async def _sendVehicleSecurity( + self, + command: UnsignedMessage, + *, + confirm_broadcast: Callable[[VehicleStatus], bool] | None = None, + ) -> dict[str, Any]: """Send a VCSEC actuation. + ``confirm_broadcast`` is accepted only for signature compatibility + with ``Commands._sendVehicleSecurity``; this override always derives + its own predicate from ``command`` (see ``plan`` below) and ignores + any value passed here. + A lost ack after the write reached the vehicle is unconfirmed, not - failed, so a timed-out wait raises ``BluetoothUnconfirmedCommand`` + failed; when the ladder remains unresolved, + ``raise_unconfirmed=True`` 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. With - ``optimistic`` on, the write itself is the whole outcome and none of - this ladder runs. With ``raise_unconfirmed`` off, an exhausted ladder - resolves as a best-effort success instead of raising - see the class - docstring. + docstring. For a command with a verify plan (lock/unlock), the same + wait window also races a matching VCSEC status broadcast against the + ack - live-verified, the vehicle keeps broadcasting status on the same + subscription even when it emits no addressed ack, so a broadcast + showing the requested end state confirms success before the ack + timeout is ever reached. If the window instead elapses with a + mismatching broadcast as the last word (see + ``_await_response_or_broadcast``) or, with ``confirmation="verify"``, a + post-timeout read that contradicts the request, that is proven + non-application and raises ``BluetoothCommandFailed`` - not the + ambiguous ``BluetoothUnconfirmedCommand`` - since a fallback router + can safely fail over on a command proven not to have applied. With + ``confirmation="optimistic"``, the write itself is the whole outcome + and none of this ladder runs. With ``raise_unconfirmed`` off, only a + ladder that is still genuinely unresolved resolves as a best-effort + success instead of raising - see the class docstring. """ await self._ensure_handshake(Domain.DOMAIN_VEHICLE_SECURITY) - if self.optimistic: + if self.confirmation == "optimistic": return await self._send_optimistic( Domain.DOMAIN_VEHICLE_SECURITY, command.SerializeToString() ) + plan = _vcsec_verify_plan(command) try: - return await super()._sendVehicleSecurity(command) + return await super()._sendVehicleSecurity( + command, confirm_broadcast=plan[1] if plan else None + ) except BluetoothTimeout as timeout: unconfirmed = BluetoothUnconfirmedCommand(timeout.data, timeout.status) name = vcsec_command_name(command) - if self.verify_commands: - result = await self._resolve_timeout( - _vcsec_verify_plan(command), unconfirmed - ) + if self.confirmation == "verify": + result = await self._resolve_timeout(plan, unconfirmed) if result is not None: LOGGER.debug( "command=%s transport=%s verify_commands=resolved result=%s", @@ -880,13 +1141,13 @@ async def _sendInfotainment( ) -> dict[str, Any]: """Send an infotainment command. - Same unconfirmed-ack, ``optimistic``, and ``raise_unconfirmed`` + Same unconfirmed-ack, ``confirmation``, and ``raise_unconfirmed`` semantics as ``_sendVehicleSecurity`` - see there. ``mutating=False`` (``ping()`` only) is exempt from all three: it always waits for its real reply. """ await self._ensure_handshake(Domain.DOMAIN_INFOTAINMENT) - if self.optimistic and mutating: + if self.confirmation == "optimistic" and mutating: return await self._send_optimistic( Domain.DOMAIN_INFOTAINMENT, command.SerializeToString() ) @@ -897,7 +1158,7 @@ async def _sendInfotainment( raise unconfirmed = BluetoothUnconfirmedCommand(timeout.data, timeout.status) name = infotainment_command_name(command) - if self.verify_commands: + if self.confirmation == "verify": resolver = _INFOTAINMENT_VERIFY_PLANS.get( command.vehicleAction.WhichOneof("vehicle_action_msg") ) @@ -929,8 +1190,11 @@ async def _resolve_timeout( ``TeslaFleetError`` from a sleeping car is not woken just to verify). ``None`` leaves the caller to resolve the outcome via ``raise_unconfirmed``. A verify mismatch - the read completed and does - not show the requested value - is unambiguous negative evidence and - raises ``timeout`` directly instead, regardless of that setting. + not show the requested value - is unambiguous proof the command did + not apply, so it raises ``BluetoothCommandFailed`` instead of the + ambiguous ``timeout``, regardless of ``raise_unconfirmed``: a fallback + router may safely fail over on proven non-application, unlike on an + unresolved timeout where a replay could double-execute the command. """ if plan is None: return None @@ -941,7 +1205,7 @@ async def _resolve_timeout( return None if executed(state): return {"response": {"result": True, "reason": ""}} - raise timeout + raise BluetoothCommandFailed(timeout.data, timeout.status) from timeout async def pair( self, diff --git a/tesla_fleet_api/tesla/vehicle/commands.py b/tesla_fleet_api/tesla/vehicle/commands.py index 9fb483f..a1a7ca4 100644 --- a/tesla_fleet_api/tesla/vehicle/commands.py +++ b/tesla_fleet_api/tesla/vehicle/commands.py @@ -3,7 +3,16 @@ import struct from random import randbytes -from typing import Any, TYPE_CHECKING, ClassVar, Generic, Literal, TypeVar, cast +from typing import ( + Any, + TYPE_CHECKING, + Callable, + ClassVar, + Generic, + Literal, + TypeVar, + cast, +) import time import hmac import hashlib @@ -375,13 +384,22 @@ def shared_key(self, vehicleKey: bytes) -> bytes: @abstractmethod async def _send( - self, msg: RoutableMessage, requires: str, expects_data: bool = True + self, + msg: RoutableMessage, + requires: str, + expects_data: bool = True, + *, + confirm_broadcast: Callable[[VehicleStatus], bool] | None = None, ) -> RoutableMessage: """Transmit the message to the vehicle. ``expects_data`` is False when the reply is a single terminal ack with no following data frame (a VCSEC actuation), letting a framing transport return on that ack instead of waiting out a data follow-up. + ``confirm_broadcast``, when given, lets a transport that also observes + unsolicited state broadcasts (BLE) treat a matching broadcast as an + alternate confirmation alongside the addressed ack - transports with no + such side channel (e.g. Fleet API) ignore it. """ raise NotImplementedError @@ -407,6 +425,7 @@ async def _command( command: bytes, attempt: int = 0, expects_data: bool = True, + confirm_broadcast: Callable[[VehicleStatus], bool] | None = None, ) -> dict[str, Any]: """Serialize a message and send to the signed command endpoint. @@ -417,6 +436,8 @@ async def _command( ``expects_data`` is threaded to ``_send`` and every retry; it is False for a VCSEC actuation, whose reply is a bare terminal ack. + ``confirm_broadcast`` is threaded the same way; a transport with no + broadcast side channel simply ignores it. """ session = self._sessions[domain] if not session.ready: @@ -432,7 +453,10 @@ async def _command( try: resp = await self._send( - msg, "protobuf_message_as_bytes", expects_data=expects_data + msg, + "protobuf_message_as_bytes", + expects_data=expects_data, + confirm_broadcast=confirm_broadcast, ) except ( # TeslaFleetMessageFaultInvalidSignature, @@ -444,7 +468,11 @@ async def _command( # We tried 3 times, give up, raise the error raise e return await self._command( - domain, command, attempt, expects_data=expects_data + domain, + command, + attempt, + expects_data=expects_data, + confirm_broadcast=confirm_broadcast, ) if ( @@ -458,7 +486,11 @@ async def _command( async with session.lock: await sleep(2) return await self._command( - domain, command, attempt, expects_data=expects_data + domain, + command, + attempt, + expects_data=expects_data, + confirm_broadcast=confirm_broadcast, ) if resp.HasField("protobuf_message_as_bytes"): @@ -543,6 +575,12 @@ async def _command( } } elif vcsec.HasField("vehicleStatus"): + if not expects_data: + # An actuation has no data reply of its own; a + # populated vehicleStatus here is our own broadcast + # substitution for a lost ack (see _send), not a + # requested read - report it as an actuation success. + return {"response": {"result": True, "reason": ""}} return {"response": vcsec.vehicleStatus} elif ( vcsec.commandStatus.operationStatus @@ -562,7 +600,11 @@ async def _command( async with session.lock: await sleep(2) return await self._command( - domain, command, attempt, expects_data=expects_data + domain, + command, + attempt, + expects_data=expects_data, + confirm_broadcast=confirm_broadcast, ) elif ( vcsec.commandStatus.operationStatus @@ -716,11 +758,18 @@ async def _commandAes( flags=flags, ) - async def _sendVehicleSecurity(self, command: UnsignedMessage) -> dict[str, Any]: + async def _sendVehicleSecurity( + self, + command: UnsignedMessage, + *, + confirm_broadcast: Callable[[VehicleStatus], bool] | None = None, + ) -> dict[str, Any]: """Sign and send an actuation to the Vehicle Security computer. A VCSEC actuation replies with a single terminal ack and no data frame, so ``expects_data=False`` lets ``_send`` return on that ack. + ``confirm_broadcast`` is passed through to ``_send``; only a transport + with a broadcast side channel (BLE) acts on it. """ name = vcsec_command_name(command) try: @@ -728,6 +777,7 @@ async def _sendVehicleSecurity(self, command: UnsignedMessage) -> dict[str, Any] Domain.DOMAIN_VEHICLE_SECURITY, command.SerializeToString(), expects_data=False, + confirm_broadcast=confirm_broadcast, ) except (Exception, TeslaFleetError) as e: _log_command_error(name, self._transport_name, e) diff --git a/tesla_fleet_api/tesla/vehicle/signed.py b/tesla_fleet_api/tesla/vehicle/signed.py index e58338a..7040957 100644 --- a/tesla_fleet_api/tesla/vehicle/signed.py +++ b/tesla_fleet_api/tesla/vehicle/signed.py @@ -1,7 +1,7 @@ from __future__ import annotations import base64 -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet from tesla_fleet_api.tesla.vehicle.commands import Commands @@ -29,11 +29,17 @@ def __init__(self, parent: SignedParentT, vin: str): super(Commands, self).__init__(parent, vin) async def _send( - self, msg: RoutableMessage, requires: str, expects_data: bool = True + self, + msg: RoutableMessage, + requires: str, + expects_data: bool = True, + *, + confirm_broadcast: Callable[[Any], bool] | None = None, ) -> RoutableMessage: """Serialize a message and send to the signed command endpoint.""" - # requires and expects_data are unused: Fleet API replies are singular, - # delivered whole in one response with no separate terminal ack frame. + # requires, expects_data, and confirm_broadcast are unused: Fleet API + # replies are singular, delivered whole in one response with no + # separate terminal ack frame and no broadcast side channel. async with self._sessions[msg.to_destination.domain].lock: json = await self.signed_command( diff --git a/tesla_fleet_api/tesla/vehicle/vehicles.py b/tesla_fleet_api/tesla/vehicle/vehicles.py index 71e0d89..8bf695c 100644 --- a/tesla_fleet_api/tesla/vehicle/vehicles.py +++ b/tesla_fleet_api/tesla/vehicle/vehicles.py @@ -4,6 +4,7 @@ from bleak.backends.device import BLEDevice from cryptography.hazmat.primitives.asymmetric import ec +from tesla_fleet_api.const import BluetoothConfirmation from tesla_fleet_api.tesla.vehicle.signed import VehicleSigned from tesla_fleet_api.tesla.vehicle.bluetooth import ( DEFAULT_KEEPALIVE_INTERVAL, @@ -46,32 +47,35 @@ def createSigned(self, vin: str) -> VehicleSigned[FleetParentT]: def createBluetooth( self, vin: str, - verify_commands: bool = False, + confirmation: BluetoothConfirmation | bool = "ack", keepalive_interval: float | None = DEFAULT_KEEPALIVE_INTERVAL, - optimistic: bool = False, - raise_unconfirmed: bool = True, + optimistic: bool | None = None, + raise_unconfirmed: bool = False, + *, + verify_commands: bool | None = None, ) -> VehicleBluetooth[FleetParentT]: """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 an unconfirmed - command timeout. + ``confirmation`` sets the confirmation ladder depth: ``"optimistic"`` + consults nothing, ``"ack"`` (default) waits for an addressed ack or a + matching state broadcast, ``"verify"`` additionally reads back state + on an ack/broadcast timeout. ``keepalive_interval`` seconds of GATT idleness triggers a passive read to hold the link open (``None``/``0`` disables). - ``optimistic`` returns success as soon as a mutating command's GATT - write is confirmed, skipping the ack wait and ``verify_commands``. - ``raise_unconfirmed=False`` resolves an exhausted confirmation ladder - as a best-effort success instead of raising - ``BluetoothUnconfirmedCommand``. See ``VehicleBluetooth``'s docstring - for the full ladder and what each flag does and does not affect. + ``raise_unconfirmed=True`` raises ``BluetoothUnconfirmedCommand`` + instead of resolving a still-inconclusive ladder as a best-effort + success. ``verify_commands``/``optimistic`` are deprecated aliases for + ``confirmation="verify"``/``confirmation="optimistic"``. See + ``VehicleBluetooth``'s docstring for the full ladder. """ vehicle = self.Bluetooth( self._parent, vin, - verify_commands=verify_commands, + confirmation=confirmation, keepalive_interval=keepalive_interval, optimistic=optimistic, raise_unconfirmed=raise_unconfirmed, + verify_commands=verify_commands, ) self[vin] = vehicle return vehicle @@ -99,33 +103,36 @@ def create( vin: str, key: ec.EllipticCurvePrivateKey | None = None, device: BLEDevice | None = None, - verify_commands: bool = False, + confirmation: BluetoothConfirmation | bool = "ack", keepalive_interval: float | None = DEFAULT_KEEPALIVE_INTERVAL, - optimistic: bool = False, - raise_unconfirmed: bool = True, + optimistic: bool | None = None, + raise_unconfirmed: bool = False, + *, + verify_commands: bool | None = None, ) -> VehicleBluetooth[BluetoothClientT]: """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 an unconfirmed - command timeout. + ``confirmation`` sets the confirmation ladder depth: ``"optimistic"`` + consults nothing, ``"ack"`` (default) waits for an addressed ack or a + matching state broadcast, ``"verify"`` additionally reads back state + on an ack/broadcast timeout. ``keepalive_interval`` seconds of GATT idleness triggers a passive read to hold the link open (``None``/``0`` disables). - ``optimistic`` returns success as soon as a mutating command's GATT - write is confirmed, skipping the ack wait and ``verify_commands``. - ``raise_unconfirmed=False`` resolves an exhausted confirmation ladder - as a best-effort success instead of raising - ``BluetoothUnconfirmedCommand``. See ``VehicleBluetooth``'s docstring - for the full ladder and what each flag does and does not affect. + ``raise_unconfirmed=True`` raises ``BluetoothUnconfirmedCommand`` + instead of resolving a still-inconclusive ladder as a best-effort + success. ``verify_commands``/``optimistic`` are deprecated aliases for + ``confirmation="verify"``/``confirmation="optimistic"``. See + ``VehicleBluetooth``'s docstring for the full ladder. """ return self.createBluetooth( vin, key, device, - verify_commands, + confirmation, keepalive_interval, optimistic, raise_unconfirmed, + verify_commands=verify_commands, ) def createBluetooth( @@ -133,34 +140,37 @@ def createBluetooth( vin: str, key: ec.EllipticCurvePrivateKey | None = None, device: BLEDevice | None = None, - verify_commands: bool = False, + confirmation: BluetoothConfirmation | bool = "ack", keepalive_interval: float | None = DEFAULT_KEEPALIVE_INTERVAL, - optimistic: bool = False, - raise_unconfirmed: bool = True, + optimistic: bool | None = None, + raise_unconfirmed: bool = False, + *, + verify_commands: bool | None = None, ) -> VehicleBluetooth[BluetoothClientT]: """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 an unconfirmed - command timeout. + ``confirmation`` sets the confirmation ladder depth: ``"optimistic"`` + consults nothing, ``"ack"`` (default) waits for an addressed ack or a + matching state broadcast, ``"verify"`` additionally reads back state + on an ack/broadcast timeout. ``keepalive_interval`` seconds of GATT idleness triggers a passive read to hold the link open (``None``/``0`` disables). - ``optimistic`` returns success as soon as a mutating command's GATT - write is confirmed, skipping the ack wait and ``verify_commands``. - ``raise_unconfirmed=False`` resolves an exhausted confirmation ladder - as a best-effort success instead of raising - ``BluetoothUnconfirmedCommand``. See ``VehicleBluetooth``'s docstring - for the full ladder and what each flag does and does not affect. + ``raise_unconfirmed=True`` raises ``BluetoothUnconfirmedCommand`` + instead of resolving a still-inconclusive ladder as a best-effort + success. ``verify_commands``/``optimistic`` are deprecated aliases for + ``confirmation="verify"``/``confirmation="optimistic"``. See + ``VehicleBluetooth``'s docstring for the full ladder. """ vehicle = self.Bluetooth( self._parent, vin, key, device, - verify_commands=verify_commands, + confirmation=confirmation, keepalive_interval=keepalive_interval, optimistic=optimistic, raise_unconfirmed=raise_unconfirmed, + verify_commands=verify_commands, ) self[vin] = vehicle return vehicle diff --git a/tesla_fleet_api/teslemetry/vehicle.py b/tesla_fleet_api/teslemetry/vehicle.py index d10619c..12359ad 100644 --- a/tesla_fleet_api/teslemetry/vehicle.py +++ b/tesla_fleet_api/teslemetry/vehicle.py @@ -2,7 +2,12 @@ from typing import TYPE_CHECKING, Any -from tesla_fleet_api.const import Method, ClosureState, SeatHeaterLevel +from tesla_fleet_api.const import ( + BluetoothConfirmation, + Method, + ClosureState, + SeatHeaterLevel, +) from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet from tesla_fleet_api.const import LOGGER @@ -306,10 +311,12 @@ def createSigned(self, vin: str) -> Any: def createBluetooth( self, vin: str, - verify_commands: bool = False, + confirmation: BluetoothConfirmation | bool = "ack", keepalive_interval: float | None = None, - optimistic: bool = False, - raise_unconfirmed: bool = True, + optimistic: bool | None = None, + raise_unconfirmed: bool = False, + *, + verify_commands: bool | None = None, ) -> Any: """Not supported; parameters match the Fleet API Bluetooth factory.""" raise NotImplementedError("Teslemetry cannot use local Bluetooth") diff --git a/tesla_fleet_api/tessie/vehicle.py b/tesla_fleet_api/tessie/vehicle.py index e7e638a..4b0b71c 100644 --- a/tesla_fleet_api/tessie/vehicle.py +++ b/tesla_fleet_api/tessie/vehicle.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from tesla_fleet_api.const import Method +from tesla_fleet_api.const import BluetoothConfirmation, Method from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet @@ -1202,10 +1202,12 @@ def createSigned(self, vin: str) -> Any: def createBluetooth( self, vin: str, - verify_commands: bool = False, + confirmation: BluetoothConfirmation | bool = "ack", keepalive_interval: float | None = None, - optimistic: bool = False, - raise_unconfirmed: bool = True, + optimistic: bool | None = None, + raise_unconfirmed: bool = False, + *, + verify_commands: bool | None = None, ) -> Any: """Not supported; parameters match the Fleet API Bluetooth factory.""" raise NotImplementedError("Tessie cannot use local Bluetooth") diff --git a/tests/ble_mocked_transport.py b/tests/ble_mocked_transport.py index 139a908..1d579fc 100644 --- a/tests/ble_mocked_transport.py +++ b/tests/ble_mocked_transport.py @@ -18,6 +18,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.hashes import SHA256, Hash +from tesla_fleet_api.const import BluetoothConfirmation from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth from tesla_fleet_api.tesla.vehicle.proto.car_server_pb2 import ( ActionStatus, @@ -99,6 +100,7 @@ def make_vehicle( verify_commands: bool = False, optimistic: bool = False, raise_unconfirmed: bool = True, + confirmation: BluetoothConfirmation | None = None, ) -> tuple[VehicleBluetooth[Any], AsyncMock]: """Build a VehicleBluetooth whose ``_send`` and connection are fully mocked. @@ -106,16 +108,25 @@ def make_vehicle( set ``send.return_value``/``side_effect`` to script replies. Pass ``verify_commands=True`` to exercise the opt-in post-timeout state verification, ``optimistic=True`` to skip the ack wait entirely, or - ``raise_unconfirmed=False`` to resolve an exhausted ladder as a - best-effort success. + ``confirmation`` directly to set the ladder depth without going + through those deprecated aliases (it wins over both when given). + ``raise_unconfirmed`` defaults ``True`` here - opposite of the real + class default - purely so the many existing timeout/error tests below + keep asserting a raise without each passing it explicitly; the real + default is exercised by dedicated tests instead. """ + if confirmation is None: + confirmation = "ack" + if verify_commands: + confirmation = "verify" + if optimistic: + confirmation = "optimistic" parent = MagicMock() parent.private_key = ec.generate_private_key(ec.SECP256R1()) vehicle = VehicleBluetooth( parent, self.VIN, - verify_commands=verify_commands, - optimistic=optimistic, + confirmation=confirmation, raise_unconfirmed=raise_unconfirmed, ) diff --git a/tests/test_ble_broadcast_confirmation.py b/tests/test_ble_broadcast_confirmation.py new file mode 100644 index 0000000..949ce37 --- /dev/null +++ b/tests/test_ble_broadcast_confirmation.py @@ -0,0 +1,356 @@ +"""Tests for broadcast-as-confirmation: racing a VCSEC actuation's addressed +ack against a matching unsolicited status broadcast on the same subscription. + +Unlike the other BLE command tests, these drive the real (unmocked) ``_send`` +send/wait state machine - only the GATT client is faked - so the actual +asyncio race between the addressed-reply queue and the broadcast watcher runs +for real. Broadcasts are injected with ``vehicle._on_message(...)``, exactly +as ``_on_notify``/the reassembling buffer would deliver them from a real +notification. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, cast +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock + +from cryptography.hazmat.primitives.asymmetric import ec + +from tesla_fleet_api.exceptions import ( + BluetoothCommandFailed, + BluetoothTimeout, + BluetoothUnconfirmedCommand, +) +from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth +from tesla_fleet_api.tesla.vehicle.proto.errors_pb2 import GenericError_E, NominalError +from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import ( + Destination, + Domain, + RoutableMessage, +) +from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( + CommandStatus, + FromVCSECMessage, + OperationStatus_E, + VehicleLockState_E, + VehicleStatus, +) + +VIN = "5YJXCAE43LF123456" +DOMAIN = Domain.DOMAIN_VEHICLE_SECURITY + + +def _make_vehicle(**kwargs: Any) -> VehicleBluetooth[Any]: + """A VehicleBluetooth with real send/race logic but a faked GATT client.""" + parent = MagicMock() + parent.private_key = ec.generate_private_key(ec.SECP256R1()) + vehicle = VehicleBluetooth(parent, VIN, **kwargs) + vehicle.connect_if_needed = AsyncMock() # type: ignore[method-assign] + vehicle.client = MagicMock() + vehicle.client.write_gatt_char = AsyncMock() + + # Mark both signed-command sessions ready so _command skips the + # handshake round-trip (which would otherwise also go through _send). + sessions = cast("dict[int, Any]", getattr(vehicle, "_sessions")) + for session in sessions.values(): + session.epoch = b"\x00" * 16 + session.hmac = b"\x00" * 32 + session.delta = 0 + session.sharedKey = b"\x00" * 16 + return vehicle + + +def _status_broadcast(lock_state: "VehicleLockState_E.ValueType") -> RoutableMessage: + """An unsolicited VCSEC status broadcast, not addressed to us.""" + body = FromVCSECMessage(vehicleStatus=VehicleStatus(vehicleLockState=lock_state)) + return RoutableMessage( + from_destination=Destination(domain=DOMAIN), + protobuf_message_as_bytes=body.SerializeToString(), + ) + + +def _locked_broadcast() -> RoutableMessage: + return _status_broadcast(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED) + + +def _unlocked_broadcast() -> RoutableMessage: + return _status_broadcast(VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED) + + +def _addressed_ok_ack(vehicle: VehicleBluetooth[Any]) -> RoutableMessage: + """An addressed VCSEC actuation ack reporting success.""" + body = FromVCSECMessage( + commandStatus=CommandStatus( + operationStatus=OperationStatus_E.OPERATIONSTATUS_OK + ) + ) + return RoutableMessage( + to_destination=Destination( + domain=DOMAIN, routing_address=vehicle._from_destination + ), + from_destination=Destination(domain=DOMAIN), + protobuf_message_as_bytes=body.SerializeToString(), + ) + + +def _addressed_status_reply( + vehicle: VehicleBluetooth[Any], lock_state: "VehicleLockState_E.ValueType" +) -> RoutableMessage: + """An addressed reply to a state read (e.g. the ``verify_commands`` prober).""" + body = FromVCSECMessage(vehicleStatus=VehicleStatus(vehicleLockState=lock_state)) + return RoutableMessage( + to_destination=Destination( + domain=DOMAIN, routing_address=vehicle._from_destination + ), + from_destination=Destination(domain=DOMAIN), + protobuf_message_as_bytes=body.SerializeToString(), + ) + + +def _addressed_rejection_ack(vehicle: VehicleBluetooth[Any]) -> RoutableMessage: + """An addressed VCSEC reply carrying a car-side rejection.""" + body = FromVCSECMessage( + nominalError=NominalError( + genericError=GenericError_E.GENERICERROR_VEHICLE_NOT_IN_PARK + ) + ) + return RoutableMessage( + to_destination=Destination( + domain=DOMAIN, routing_address=vehicle._from_destination + ), + from_destination=Destination(domain=DOMAIN), + protobuf_message_as_bytes=body.SerializeToString(), + ) + + +async def _wait_until_watching( + vehicle: VehicleBluetooth[Any], domain: Domain, timeout: float = 1.0 +) -> None: + """Block until the broadcast race has armed its watcher for ``domain``.""" + + async def poll() -> None: + while domain not in cast( + "dict[Any, Any]", getattr(vehicle, "_broadcast_watchers") + ): + await asyncio.sleep(0) + + await asyncio.wait_for(poll(), timeout=timeout) + + +class BroadcastConfirmsBeforeAckTests(IsolatedAsyncioTestCase): + async def test_broadcast_arriving_during_gatt_write_confirms_lock(self) -> None: + vehicle = _make_vehicle() + vehicle._actuation_timeout = 5.0 + + async def write_then_broadcast(*_: Any) -> None: + vehicle._on_message(_locked_broadcast()) + await asyncio.sleep(0) + + vehicle.client.write_gatt_char = AsyncMock(side_effect=write_then_broadcast) + + result = await asyncio.wait_for(vehicle.door_lock(), timeout=0.5) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + + async def test_broadcast_confirms_lock_before_actuation_timeout(self) -> None: + vehicle = _make_vehicle() + vehicle._actuation_timeout = 5.0 # generous ceiling the broadcast must beat + + task = asyncio.ensure_future(vehicle.door_lock()) + await _wait_until_watching(vehicle, DOMAIN) + vehicle._on_message(_locked_broadcast()) + + # No ack is ever delivered; a well-under-ceiling completion proves the + # broadcast - not a fallback timeout - resolved the call. + result = await asyncio.wait_for(task, timeout=0.5) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + + async def test_broadcast_confirms_unlock(self) -> None: + vehicle = _make_vehicle() + vehicle._actuation_timeout = 5.0 + + task = asyncio.ensure_future(vehicle.door_unlock()) + await _wait_until_watching(vehicle, DOMAIN) + vehicle._on_message(_unlocked_broadcast()) + + result = await asyncio.wait_for(task, timeout=0.5) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + + +class AckRejectionWinsTests(IsolatedAsyncioTestCase): + async def test_ack_rejection_surfaces_despite_mismatched_broadcasts(self) -> None: + vehicle = _make_vehicle() + + task = asyncio.ensure_future(vehicle.door_lock()) + await _wait_until_watching(vehicle, DOMAIN) + # A broadcast showing the wrong state must not confirm and must not + # block the addressed rejection from winning the race. + vehicle._on_message(_unlocked_broadcast()) + vehicle._on_message(_addressed_rejection_ack(vehicle)) + + result = await asyncio.wait_for(task, timeout=1.0) + + self.assertEqual(result["response"]["result"], False) + + async def test_ack_success_wins_over_no_broadcast(self) -> None: + vehicle = _make_vehicle() + + task = asyncio.ensure_future(vehicle.door_lock()) + await _wait_until_watching(vehicle, DOMAIN) + vehicle._on_message(_addressed_ok_ack(vehicle)) + + result = await asyncio.wait_for(task, timeout=1.0) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + + +class StatelessCommandUnaffectedTests(IsolatedAsyncioTestCase): + async def test_command_without_a_plan_never_arms_a_broadcast_watcher(self) -> None: + # charge_port_door_open has no derivable expected end state, so it + # must not race broadcasts at all - a broadcast arriving during its + # wait is simply irrelevant, exactly as before this feature existed. + vehicle = _make_vehicle(raise_unconfirmed=True) + vehicle._actuation_timeout = 0.05 + + task = asyncio.ensure_future(vehicle.charge_port_door_open()) + await asyncio.sleep(0.01) + self.assertNotIn( + DOMAIN, cast("dict[Any, Any]", getattr(vehicle, "_broadcast_watchers")) + ) + vehicle._on_message(_locked_broadcast()) + + with self.assertRaises(BluetoothUnconfirmedCommand): + await asyncio.wait_for(task, timeout=1.0) + + +class MismatchedBroadcastTests(IsolatedAsyncioTestCase): + async def test_prewrite_stale_mismatch_stays_unconfirmed(self) -> None: + vehicle = _make_vehicle(raise_unconfirmed=True) + vehicle._actuation_timeout = 0.05 + + async def write_after_stale_broadcast(*_: Any) -> None: + vehicle._on_message(_unlocked_broadcast()) + await asyncio.sleep(0) + + vehicle.client.write_gatt_char = AsyncMock( + side_effect=write_after_stale_broadcast + ) + + with self.assertRaises(BluetoothUnconfirmedCommand): + await asyncio.wait_for(vehicle.door_lock(), timeout=1.0) + + async def test_mismatch_standing_at_window_end_raises_command_failed(self) -> None: + # If the whole window elapses with a mismatching broadcast as the + # last word and nothing else confirming, that is now-final proof the + # command did not apply - a distinct, fail-over-safe signal, not the + # ambiguous BluetoothUnconfirmedCommand a total silence would raise. + vehicle = _make_vehicle(raise_unconfirmed=True) + vehicle._actuation_timeout = 0.05 + + task = asyncio.ensure_future(vehicle.door_lock()) + await _wait_until_watching(vehicle, DOMAIN) + vehicle._on_message(_unlocked_broadcast()) + + with self.assertRaises(BluetoothCommandFailed): + await asyncio.wait_for(task, timeout=1.0) + + async def test_total_silence_still_raises_unconfirmed_not_command_failed( + self, + ) -> None: + # Contrast: with no broadcast at all (not even a mismatching one), + # the outcome stays the ambiguous BluetoothUnconfirmedCommand - only + # an actual observed mismatch upgrades to BluetoothCommandFailed. + vehicle = _make_vehicle(raise_unconfirmed=True) + vehicle._actuation_timeout = 0.05 + + with self.assertRaises(BluetoothUnconfirmedCommand): + await asyncio.wait_for(vehicle.door_lock(), timeout=1.0) + + +class SimultaneousCompletionTests(IsolatedAsyncioTestCase): + async def test_successful_broadcast_wins_over_simultaneous_response_timeout( + self, + ) -> None: + vehicle = _make_vehicle() + broadcast_future = asyncio.get_running_loop().create_future() + broadcast = _locked_broadcast() + broadcast_future.set_result(broadcast) + + async def response_timeout(*_: Any) -> RoutableMessage: + raise BluetoothTimeout() + + vehicle._await_response = response_timeout # type: ignore[method-assign] + + result = await vehicle._await_response_or_broadcast( + DOMAIN, + RoutableMessage(), + "protobuf_message_as_bytes", + False, + 0.0, + broadcast_future, + None, + [], + ) + + self.assertIs(result, broadcast) + + +class DefaultsAndFlagInterplayTests(IsolatedAsyncioTestCase): + async def test_optimistic_never_arms_a_broadcast_watcher(self) -> None: + vehicle = _make_vehicle(confirmation="optimistic") + + result = await asyncio.wait_for(vehicle.door_lock(), timeout=1.0) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + self.assertEqual( + cast("dict[Any, Any]", getattr(vehicle, "_broadcast_watchers")), {} + ) + + async def test_raise_unconfirmed_false_best_effort_after_broadcast_miss( + self, + ) -> None: + # raise_unconfirmed semantics are unchanged by broadcast confirmation + # - it only makes the unconfirmed case rarer, never changes what + # happens once the ladder is genuinely exhausted with no evidence + # either way. + vehicle = _make_vehicle(raise_unconfirmed=False) + vehicle._actuation_timeout = 0.05 + + task = asyncio.ensure_future(vehicle.charge_port_door_open()) + await asyncio.sleep(0.01) + + result = await asyncio.wait_for(task, timeout=1.0) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + + async def test_verify_commands_runs_only_after_ack_and_broadcast_both_miss( + self, + ) -> None: + vehicle = _make_vehicle(confirmation="verify") + vehicle._actuation_timeout = 0.05 + + task = asyncio.ensure_future(vehicle.door_lock()) + await _wait_until_watching(vehicle, DOMAIN) + + async def deliver_verify_read_reply() -> None: + # Wait for the ack/broadcast window to elapse before answering + # the post-timeout verify_commands read - if verify ran any + # earlier than that, this reply would be consumed as the (wrong) + # addressed ack for the original actuation instead. The verify + # read waits on the addressed queue, not broadcasts. + await asyncio.sleep(0.2) + vehicle._on_message( + _addressed_status_reply( + vehicle, VehicleLockState_E.VEHICLELOCKSTATE_LOCKED + ) + ) + + asyncio.ensure_future(deliver_verify_read_reply()) + + result = await asyncio.wait_for(task, timeout=2.0) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) diff --git a/tests/test_ble_command_verification.py b/tests/test_ble_command_verification.py index b8700f6..80e5266 100644 --- a/tests/test_ble_command_verification.py +++ b/tests/test_ble_command_verification.py @@ -4,9 +4,10 @@ can execute the command without its ack reaching the client. With ``verify_commands=True``, a timed-out mutation whose expected post-state is derivable from its arguments is confirmed by reading the mapped prover state: -verified-executed returns a normal success result, verified-not-executed -re-raises the timeout, and an unverifiable command re-raises unchanged. With the -default (``verify_commands=False``) every path is byte-identical to today, and no +verified-executed returns a normal success result, verified-not-executed raises +``BluetoothCommandFailed`` (proven non-application, not ambiguity), and an +unverifiable command re-raises the timeout unchanged. With the default +(``verify_commands=False``) every path is byte-identical to today, and no verification read is ever issued. The single mocked ``_send`` is scripted with a list ``side_effect``: the first @@ -16,7 +17,7 @@ from __future__ import annotations -from tesla_fleet_api.exceptions import BluetoothTimeout +from tesla_fleet_api.exceptions import BluetoothCommandFailed, BluetoothTimeout from tesla_fleet_api.tesla.vehicle.proto.vehicle_pb2 import ( ChargeState, ClimateState, @@ -57,7 +58,9 @@ async def test_verified_executed_returns_success(self) -> None: # Command send + one prover read. self.assertEqual(send.await_count, 2) - async def test_verified_not_executed_reraises(self) -> None: + async def test_verified_not_executed_raises_command_failed(self) -> None: + # A verify-read mismatch is proof the command did not apply, not + # ambiguity - it raises BluetoothCommandFailed, not the timeout. vehicle, send = self.make_vehicle(verify_commands=True) send.side_effect = [ BluetoothTimeout(), @@ -68,7 +71,7 @@ async def test_verified_not_executed_reraises(self) -> None: ), ] - with self.assertRaises(BluetoothTimeout): + with self.assertRaises(BluetoothCommandFailed): await vehicle.door_lock() self.assertEqual(send.await_count, 2) @@ -113,7 +116,7 @@ async def test_set_charge_limit_verified_executed(self) -> None: self.assertEqual(result, {"response": {"result": True, "reason": ""}}) self.assertEqual(send.await_count, 2) - async def test_set_charge_limit_mismatch_reraises(self) -> None: + async def test_set_charge_limit_mismatch_raises_command_failed(self) -> None: vehicle, send = self.make_vehicle(verify_commands=True) send.side_effect = [ BluetoothTimeout(), @@ -122,7 +125,7 @@ async def test_set_charge_limit_mismatch_reraises(self) -> None: ), ] - with self.assertRaises(BluetoothTimeout): + with self.assertRaises(BluetoothCommandFailed): await vehicle.set_charge_limit(80) async def test_set_charging_amps_verified_executed(self) -> None: diff --git a/tests/test_ble_confirmation_mode.py b/tests/test_ble_confirmation_mode.py new file mode 100644 index 0000000..c3be9fa --- /dev/null +++ b/tests/test_ble_confirmation_mode.py @@ -0,0 +1,223 @@ +"""Tests for the ``confirmation`` ladder-depth setting on ``VehicleBluetooth``. + +``confirmation`` ("optimistic" | "ack" | "verify") replaces the old +``optimistic``/``verify_commands`` booleans as the single ladder-depth choice; +``raise_unconfirmed`` stays a separate orthogonal bool for what to do once a +ladder is still genuinely inconclusive. The old booleans remain as deprecated +constructor aliases (mapped onto ``confirmation``, with a ``DeprecationWarning``) +and as read-only properties for existing callers. +""" + +from __future__ import annotations + +import warnings +from unittest import TestCase +from unittest.mock import MagicMock + +from cryptography.hazmat.primitives.asymmetric import ec + +from tesla_fleet_api.exceptions import BluetoothTimeout, BluetoothUnconfirmedCommand +from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth +from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles, VehiclesBluetooth +from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( + VehicleLockState_E, + VehicleStatus, +) + +from ble_mocked_transport import MockedBleTransportTestCase, vcsec_vehicle_status_reply + +VIN = "5YJXCAE43LF123456" + + +def _make_parent() -> MagicMock: + parent = MagicMock() + parent.private_key = ec.generate_private_key(ec.SECP256R1()) + return parent + + +class DefaultsTests(TestCase): + """The class's own defaults, independent of any test-harness convenience default.""" + + def test_new_defaults_are_ack_and_raise_unconfirmed_false(self) -> None: + vehicle = VehicleBluetooth(_make_parent(), VIN) + + self.assertEqual(vehicle.confirmation, "ack") + self.assertFalse(vehicle.raise_unconfirmed) + self.assertFalse(vehicle.optimistic) + self.assertFalse(vehicle.verify_commands) + + def test_invalid_confirmation_raises_value_error(self) -> None: + with self.assertRaisesRegex(ValueError, "confirmation must be one of"): + VehicleBluetooth( + _make_parent(), + VIN, + confirmation="verfy", # type: ignore[arg-type] + ) + + def test_factory_invalid_confirmation_raises_value_error(self) -> None: + vehicles = Vehicles(_make_parent()) + + with self.assertRaisesRegex(ValueError, "confirmation must be one of"): + vehicles.createBluetooth( + VIN, + confirmation="verfy", # type: ignore[arg-type] + ) + + +class DeprecatedArgMappingTests(TestCase): + """The deprecated ``verify_commands``/``optimistic`` constructor aliases.""" + + def test_verify_commands_true_warns_and_maps_to_verify(self) -> None: + with self.assertWarns(DeprecationWarning): + vehicle = VehicleBluetooth(_make_parent(), VIN, verify_commands=True) + + self.assertEqual(vehicle.confirmation, "verify") + self.assertTrue(vehicle.verify_commands) + self.assertFalse(vehicle.optimistic) + + def test_optimistic_true_warns_and_maps_to_optimistic(self) -> None: + with self.assertWarns(DeprecationWarning): + vehicle = VehicleBluetooth(_make_parent(), VIN, optimistic=True) + + self.assertEqual(vehicle.confirmation, "optimistic") + self.assertTrue(vehicle.optimistic) + self.assertFalse(vehicle.verify_commands) + + def test_verify_commands_false_still_warns_but_leaves_default_confirmation( + self, + ) -> None: + # Passing the deprecated arg at all is deprecated usage, even if the + # value itself wouldn't change anything. + with self.assertWarns(DeprecationWarning): + vehicle = VehicleBluetooth(_make_parent(), VIN, verify_commands=False) + + self.assertEqual(vehicle.confirmation, "ack") + + def test_positional_verify_commands_maps_to_confirmation(self) -> None: + with self.assertWarns(DeprecationWarning): + vehicle = VehicleBluetooth(_make_parent(), VIN, None, None, True) + + self.assertEqual(vehicle.confirmation, "verify") + + def test_positional_optimistic_slot_still_maps_to_optimistic(self) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + vehicle = VehicleBluetooth( + _make_parent(), VIN, None, None, False, None, True + ) + + self.assertEqual(vehicle.confirmation, "optimistic") + self.assertFalse(vehicle.raise_unconfirmed) + + def test_vehicle_factory_preserves_old_positional_bool_order(self) -> None: + vehicles = Vehicles(_make_parent()) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + vehicle = vehicles.createBluetooth(VIN, True, None, True, False) + + self.assertEqual(vehicle.confirmation, "optimistic") + self.assertFalse(vehicle.raise_unconfirmed) + + def test_bluetooth_collection_preserves_old_positional_bool_order(self) -> None: + vehicles = VehiclesBluetooth(_make_parent()) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + vehicle = vehicles.createBluetooth(VIN, None, None, True, None, True, False) + + self.assertEqual(vehicle.confirmation, "optimistic") + self.assertFalse(vehicle.raise_unconfirmed) + + def test_optimistic_true_dominates_verify_commands_true(self) -> None: + # Matches the old three-boolean dominance order: optimistic wins. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + vehicle = VehicleBluetooth( + _make_parent(), VIN, verify_commands=True, optimistic=True + ) + + self.assertEqual(vehicle.confirmation, "optimistic") + + def test_optimistic_and_verify_commands_are_read_only(self) -> None: + vehicle = VehicleBluetooth(_make_parent(), VIN) + + with self.assertRaises(AttributeError): + vehicle.verify_commands = True # type: ignore[misc] + with self.assertRaises(AttributeError): + vehicle.optimistic = True # type: ignore[misc] + + +class ConfirmationLadderTests(MockedBleTransportTestCase): + """Each ``confirmation`` rung behaves as documented.""" + + async def test_optimistic_returns_on_write_without_waiting(self) -> None: + vehicle, send = self.make_vehicle(confirmation="optimistic") + + result = await vehicle.door_lock() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + send.assert_awaited_once() + + async def test_ack_mode_raises_unconfirmed_on_timeout_when_opted_in(self) -> None: + vehicle, send = self.make_vehicle(confirmation="ack", raise_unconfirmed=True) + send.side_effect = [BluetoothTimeout()] + + with self.assertRaises(BluetoothUnconfirmedCommand): + await vehicle.door_lock() + + async def test_verify_mode_confirms_via_prover_read(self) -> None: + vehicle, send = self.make_vehicle(confirmation="verify") + send.side_effect = [ + BluetoothTimeout(), + vcsec_vehicle_status_reply( + VehicleStatus( + vehicleLockState=VehicleLockState_E.VEHICLELOCKSTATE_LOCKED + ) + ), + ] + + result = await vehicle.door_lock() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + self.assertEqual(send.await_count, 2) + + async def test_ack_mode_never_issues_a_verify_read(self) -> None: + # "ack" must not fall through to a state-read - that's "verify" only. + vehicle, send = self.make_vehicle(confirmation="ack", raise_unconfirmed=False) + send.side_effect = [BluetoothTimeout()] + + result = await vehicle.door_lock() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + send.assert_awaited_once() + + async def test_ack_with_raise_unconfirmed_false_matches_production_defaults( + self, + ) -> None: + # These match VehicleBluetooth's own defaults - see DefaultsTests for + # the direct-construction check; this exercises their behavior. + vehicle, send = self.make_vehicle(confirmation="ack", raise_unconfirmed=False) + send.side_effect = [BluetoothTimeout()] + + result = await vehicle.door_lock() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + + +class MootComboTests(MockedBleTransportTestCase): + """Combinations invalid only under the old three-boolean surface are moot, not errors.""" + + async def test_optimistic_with_raise_unconfirmed_true_is_moot_not_error( + self, + ) -> None: + # optimistic never reaches an unconfirmed outcome, so raise_unconfirmed + # simply never gets consulted - constructing/using both must not raise. + vehicle, send = self.make_vehicle( + confirmation="optimistic", raise_unconfirmed=True + ) + + result = await vehicle.door_lock() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + send.assert_awaited_once() diff --git a/tests/test_ble_optimistic_and_best_effort.py b/tests/test_ble_optimistic_and_best_effort.py index e09fe23..f984fda 100644 --- a/tests/test_ble_optimistic_and_best_effort.py +++ b/tests/test_ble_optimistic_and_best_effort.py @@ -17,6 +17,7 @@ from __future__ import annotations from tesla_fleet_api.exceptions import ( + BluetoothCommandFailed, BluetoothTimeout, BluetoothTransportError, BluetoothUnconfirmedCommand, @@ -72,14 +73,19 @@ async def test_non_mutating_infotainment_is_unaffected(self) -> None: self.assertEqual(result["response"]["result"], True) send.assert_awaited_once() - async def test_verify_commands_is_never_consulted(self) -> None: - # Even with verify_commands=True, optimistic mode must never issue a - # second (prover) send - it never waits long enough to time out. - vehicle, send = self.make_vehicle(optimistic=True) - vehicle.verify_commands = True + async def test_optimistic_and_raise_unconfirmed_combo_is_moot_not_error( + self, + ) -> None: + # confirmation="optimistic" makes raise_unconfirmed irrelevant rather + # than an error - optimistic never reaches an unconfirmed outcome to + # apply it to, and construction/use with both set must not raise. + vehicle, send = self.make_vehicle( + confirmation="optimistic", raise_unconfirmed=True + ) - await vehicle.door_lock() + result = await vehicle.door_lock() + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) send.assert_awaited_once() @@ -146,8 +152,8 @@ async def test_verify_commands_confirmed_success_is_unaffected(self) -> None: async def test_verify_commands_mismatch_still_raises(self) -> None: # A prover read that disagrees is affirmative evidence, not mere - # absence of confirmation - it must raise regardless of - # raise_unconfirmed. + # absence of confirmation - it must raise BluetoothCommandFailed + # regardless of raise_unconfirmed. vehicle, send = self.make_vehicle(verify_commands=True, raise_unconfirmed=False) send.side_effect = [ BluetoothTimeout(), @@ -158,7 +164,7 @@ async def test_verify_commands_mismatch_still_raises(self) -> None: ), ] - with self.assertRaises(BluetoothUnconfirmedCommand): + with self.assertRaises(BluetoothCommandFailed): await vehicle.door_lock() async def test_write_failure_still_raises(self) -> None: diff --git a/tests/test_ble_unconfirmed_command.py b/tests/test_ble_unconfirmed_command.py index b9bfbf0..410a431 100644 --- a/tests/test_ble_unconfirmed_command.py +++ b/tests/test_ble_unconfirmed_command.py @@ -19,6 +19,7 @@ from typing import Any, cast from tesla_fleet_api.exceptions import ( + BluetoothCommandFailed, BluetoothTimeout, BluetoothTransportError, BluetoothUnconfirmedCommand, @@ -86,9 +87,10 @@ async def test_unconfirmed_chains_the_original_timeout(self) -> None: 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. + async def test_verify_commands_mismatch_raises_command_failed(self) -> None: + # With verify_commands on, a prover read that disagrees is proof the + # command did not apply, distinct from an unresolved ack timeout - it + # raises BluetoothCommandFailed, not BluetoothUnconfirmedCommand. vehicle, send = self.make_vehicle(verify_commands=True) send.side_effect = [ BluetoothTimeout(), @@ -99,9 +101,18 @@ async def test_verify_commands_unresolved_raises_unconfirmed(self) -> None: ), ] - with self.assertRaises(BluetoothUnconfirmedCommand): + with self.assertRaises(BluetoothCommandFailed): await vehicle.door_lock() + async def test_verify_commands_no_plan_still_raises_unconfirmed(self) -> None: + # An ack timeout that verify_commands could not even attempt to + # resolve (no plan for this command) stays genuinely ambiguous. + vehicle, send = self.make_vehicle(verify_commands=True) + send.side_effect = [BluetoothTimeout()] + + with self.assertRaises(BluetoothUnconfirmedCommand): + await vehicle.charge_port_door_open() + async def test_handshake_timeout_raises_plain_bluetooth_timeout(self) -> None: vehicle, send = self.make_vehicle() sessions = cast("dict[int, Any]", getattr(vehicle, "_sessions")) diff --git a/tests/test_router.py b/tests/test_router.py index 77c97b7..f4d7cfd 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -8,7 +8,11 @@ import asyncio from unittest import IsolatedAsyncioTestCase -from tesla_fleet_api.exceptions import BluetoothTimeout, BluetoothUnconfirmedCommand +from tesla_fleet_api.exceptions import ( + BluetoothCommandFailed, + BluetoothTimeout, + BluetoothUnconfirmedCommand, +) from tesla_fleet_api.router import ( EnergySiteRouter, Router, @@ -121,6 +125,21 @@ async def test_primary_raises_unconfirmed_command_does_not_fall_back(self): self.assertEqual(primary.shared_calls, 1) self.assertEqual(fallback.shared_calls, 0) + async def test_primary_raises_command_failed_falls_back(self): + # BluetoothCommandFailed means the command was PROVEN not to have + # applied on the primary - unlike BluetoothUnconfirmedCommand, failing + # over here carries no double-execution risk, so it must fall back + # like any ordinary failure rather than propagate. + primary = _FakePrimary(exc=BluetoothCommandFailed()) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + result = await router.shared(13) + + self.assertEqual(result, "fallback:13") + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 1) + async def test_primary_raises_cancelled_error_propagates(self): # CancelledError is a BaseException but not a TeslaFleetError; it must # propagate and never trigger fallback.