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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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("<vin>", verify_commands=True)
primary = tesla_bluetooth.vehicles.create("<vin>", confirmation="verify")

# Secondary (fallback): Teslemetry cloud
teslemetry = Teslemetry(access_token="<access_token>", session=session)
Expand Down Expand Up @@ -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`).

Expand Down
193 changes: 107 additions & 86 deletions docs/bluetooth_vehicles.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions tesla_fleet_api/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions tesla_fleet_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading
Loading