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
7 changes: 4 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,14 @@ default with a passive GATT read about every 20 seconds. Pass
leaving it enabled can keep an already-awake car awake longer, so disconnect or
disable keepalive when vehicle sleep is preferred.

BLE connect/notify and GATT write failures from `VehicleBluetooth` raise
BLE connect/notify failures, and GATT writes rejected before backend I/O, raise
`BluetoothTransportError`, a `TeslaFleetError` subclass, with the original
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
transport exception chained as `__cause__` when available. A GATT write that
entered backend I/O and then failed or timed out is delivery-ambiguous and
raises `BluetoothTimeout`/`BluetoothUnconfirmedCommand` instead. 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
Expand Down
68 changes: 48 additions & 20 deletions docs/bluetooth_vehicles.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,25 @@ retry `BluetoothTimeout` with backoff. Keep one BLE connection open across
related commands when possible instead of reconnecting for each command.

`VehicleBluetooth` raises `BluetoothTransportError`, a `TeslaFleetError`
subclass, when the BLE connection, notification setup, or GATT command write
fails before a vehicle response can be awaited. The original transport
exception is available as the exception's `__cause__`; this includes
`bleak.exc.BleakError` and builtin `TimeoutError` from ESPHome proxy connect,
notify, or write timeouts. Catch `TeslaFleetError` to handle both transport
failures and response-wait timeout failures with one library error hierarchy,
or catch `BluetoothTransportError` separately when you need to distinguish a
transport failure - the command never reached the vehicle - from a vehicle
timeout after the command or request was written.
subclass, when the BLE connection, notification setup, or GATT characteristic
resolution fails *before any write reaches the backend* - provably before the
command could have reached the vehicle. The original transport exception is
available as the exception's `__cause__`. Catch `TeslaFleetError` to handle
both transport failures and response-wait timeout failures with one library
error hierarchy, or catch `BluetoothTransportError` separately when you need
to distinguish a provable pre-submission failure from a vehicle timeout after
the command or request may have been written.

A GATT write that enters the backend (D-Bus, CoreBluetooth, an ESPHome proxy)
and then fails or times out - `bleak.exc.BleakError`, or builtin `TimeoutError`
from an ESPHome proxy write timeout - is treated as delivery-ambiguous rather
than a transport failure: field measurements show such writes reaching the
vehicle about as often as not, so it raises `BluetoothTimeout` (or, for a
mutating command, `BluetoothUnconfirmedCommand` through the same ladder as a
lost ack - see "Mutating Command Timeouts" below) instead of
`BluetoothTransportError`. A `Router`/`VehicleRouter` primary-fallback chain
(see the top-level README) must not replay a command on this exception, which
is exactly why it is *not* classified as a transport failure.

A response-wait timeout for a *mutating* command (RKE/closure actions,
HVAC/media/charging commands, `wake_up()`) that stays genuinely unresolved is
Expand Down Expand Up @@ -195,6 +205,19 @@ 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.

| Rung | Runs under | On success | On failure/ambiguity |
| --- | --- | --- | --- |
| GATT write | every level, incl. `"optimistic"` | proceeds to the next rung (or returns, under `"optimistic"`) | pre-submission (e.g. characteristic not found): `BluetoothTransportError`, always raises. Submitted-then-failed/timed-out: ambiguous, races any armed broadcast watcher, then falls to the "genuinely unresolved" outcome below |
| Addressed ack + broadcast race (lock/unlock only) | `"ack"`, `"verify"` | returns a confirmed result | a proven mismatch at window end raises `BluetoothCommandFailed`; a lost ack with nothing else confirming falls to the next rung |
| State-read verification | `"verify"` only | returns a confirmed result | a proven mismatch raises `BluetoothCommandFailed`; an unreadable prover (e.g. asleep car) falls to the next rung |
| Genuinely unresolved outcome | every level | - | `raise_unconfirmed=False` (default): best-effort success. `raise_unconfirmed=True`: raises `BluetoothUnconfirmedCommand` |

The GATT write rung's ambiguous case is not hypothetical: field measurements
of write-level transport errors found some had already executed on the
vehicle despite the local write call failing, so a submitted-then-ambiguous
write is deliberately routed into the same unresolved-outcome handling as a
lost ack rather than treated as a safe-to-retry transport failure.

**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.
Expand Down Expand Up @@ -244,20 +267,25 @@ falls through to `raise_unconfirmed` regardless of `confirmation`.

- `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.
speed mode: the caller owns any state verification it wants afterward. A
write provably rejected before submission (`BluetoothTransportError`) always
raises; a submitted-then-ambiguous write instead follows `raise_unconfirmed`
like every other rung, since even this mode must not let a fallback router
blind-retry a command that may already have reached the car. `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.
(e.g. the car is asleep) - or the GATT write itself entered backend I/O and
then failed/timed out with delivery unprovable either way. 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 a write provably rejected before submission
(`BluetoothTransportError`) are unaffected and always raise - this flag
converts only the "could not determine what happened" outcome, and applies
under every `confirmation` level including `"optimistic"`.

```python
vehicle = tesla_bluetooth.vehicles.create(
Expand Down
6 changes: 3 additions & 3 deletions tesla_fleet_api/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

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.
# BLE command-confirmation ladder depth: "optimistic" skips reply waits after a
# confirmed write, "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] = {
Expand Down
27 changes: 19 additions & 8 deletions tesla_fleet_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,19 @@ class BluetoothTimeout(TeslaFleetError):


class BluetoothUnconfirmedCommand(BluetoothTimeout):
"""A mutating Bluetooth command timed out after it was written to the vehicle.
"""A mutating Bluetooth command has an unresolved delivery outcome.

The write succeeded, so the vehicle may have executed the command even
though its ack was lost - lock/unlock have both been observed to execute
despite this exception. Treat the outcome as unknown, not failed: verify
by reading state back when possible, and never blind-retry or re-issue
the same command on another transport, since it may already have run.
The write either completed and its ack was lost, or it entered backend I/O
and then failed/timed out in a way that cannot prove whether the vehicle
received it. The vehicle may have executed the command - lock/unlock have
both been observed to execute despite this exception. Treat the outcome as
unknown, not failed: verify by reading state back when possible, and never
blind-retry or re-issue the same command on another transport, since it may
already have run.

Subclasses ``BluetoothTimeout`` so existing ``except BluetoothTimeout``
handling still catches it, while remaining distinguishable from it (and
from ``BluetoothTransportError``, the genuine pre-write transport
from ``BluetoothTransportError``, the provable pre-submission transport
failure) for callers that want to react to the ambiguity specifically -
e.g. a BLE-primary/cloud-fallback router should not fail over on this
exception, since failing over risks double-executing the command.
Expand Down Expand Up @@ -71,7 +73,16 @@ class BluetoothCommandFailed(TeslaFleetError):


class BluetoothTransportError(TeslaFleetError):
"""The Bluetooth transport (connect, notify, or GATT write) failed before a vehicle response could be awaited."""
"""The Bluetooth transport failed provably before the write reached the vehicle.

Covers connect, notify-subscribe, and GATT characteristic-resolution
failures - all raised by the local BLE stack before any bytes go out over
the air, so a fallback router can safely retry the same command
elsewhere. A GATT write that entered backend I/O and then failed or timed
out is delivery-ambiguous instead (the write may have reached the
vehicle) and raises ``BluetoothTimeout``/``BluetoothUnconfirmedCommand``,
not this class - see those exceptions.
"""

message = (
"The Bluetooth transport failed before a vehicle response could be awaited."
Expand Down
12 changes: 6 additions & 6 deletions tesla_fleet_api/router/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ class Router(Generic[PrimaryT, SecondaryT]):
Dispatch to a callable performs *per-command* failover: the backends that
expose the method are attempted in order, and if one raises any exception
other than ``BluetoothUnconfirmedCommand`` (a connection failure or a
mid-command transport error such as a write/notify failure or a disconnect),
the same call is automatically retried on the next backend that has it, with
the same arguments. The error only propagates when every applicable backend
fails, in which case the last error is raised. Each attempted backend emits
a ``DEBUG`` log line with the routed command name, backend class, and
success/error result.
provably pre-submission transport error such as notify setup or GATT
characteristic resolution failure), the same call is automatically retried
on the next backend that has it, with the same arguments. The error only
propagates when every applicable backend fails, in which case the last error
is raised. Each attempted backend emits a ``DEBUG`` log line with the routed
command name, backend class, and success/error result.

.. warning::

Expand Down
49 changes: 36 additions & 13 deletions tesla_fleet_api/tesla/tesla.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend


class Tesla:
"""Base class describing interactions with Tesla products."""

Expand All @@ -30,7 +31,11 @@ class Tesla:
async def get_private_key(
self, path: str = "private_key.pem"
) -> ec.EllipticCurvePrivateKey:
"""Get or create the private key."""
"""Get or create the private key.

The private key is stored as an unencrypted PEM file with permissions
0o600 when created.
"""
if not exists(path):
self.private_key = ec.generate_private_key(
ec.SECP256R1(), default_backend()
Expand All @@ -43,6 +48,12 @@ async def get_private_key(
)
async with aiofiles.open(path, "wb") as key_file:
await key_file.write(pem)
try:
from os import chmod

chmod(path, 0o600)
except OSError:
pass
else:
try:
async with aiofiles.open(path, "rb") as key_file:
Expand Down Expand Up @@ -70,20 +81,28 @@ def public_pem(self) -> str:
"""Get the public key in PEM format."""
if self.private_key is None:
raise ValueError("Private key is not set")
return self.private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode('utf-8')
return (
self.private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)

@property
def public_uncompressed_point(self) -> str:
"""Get the public key in uncompressed point format."""
if self.private_key is None:
raise ValueError("Private key is not set")
return self.private_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
).hex()
return (
self.private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
.hex()
)

async def get_rsa_private_key(
self, path: str = "tedapi_rsa_private.pem", key_size: int = 4096
Expand Down Expand Up @@ -153,7 +172,11 @@ def rsa_public_pem(self) -> str:
"""Get the RSA public key in PEM (SubjectPublicKeyInfo) format."""
if self.rsa_private_key is None:
raise ValueError("RSA private key is not set")
return self.rsa_private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
return (
self.rsa_private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
Loading
Loading