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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ No release-please or version-bump automation. To ship: bump `version` in `pyproj

`exceptions.py` maps HTTP status codes and error keys to specific exception classes. `raise_for_status()` parses responses and raises the appropriate exception. Signed command faults have separate hierarchies: `TeslaFleetInformationFault`, `TeslaFleetMessageFault`, `SignedMessageInformationFault`, `WhitelistOperationStatus`.

All exceptions inherit from `TeslaFleetError(BaseException)`, deliberately **not** `Exception` — a bare `except Exception` (e.g. in retry/backoff loops around BLE reads) silently fails to catch `BluetoothTimeout` and every other library error. Catch `TeslaFleetError` (or `BaseException`) explicitly.
All exceptions inherit from `TeslaFleetError(BaseException)`, deliberately **not** `Exception` — a bare `except Exception` (e.g. in retry/backoff loops around BLE reads) silently fails to catch `BluetoothTimeout` and every other library error. Catch `TeslaFleetError` (or `BaseException`) explicitly. `VehicleBluetooth` wraps transport-layer failures (`connect`/`connect_if_needed`, the GATT write in `_send`) in `BluetoothTransportError`, a `TeslaFleetError` subclass chaining the original `bleak.exc.BleakError` as its cause — so `except TeslaFleetError` alone now catches BLE transport failures too, not just the response-wait `BluetoothTimeout`.

### Protobuf

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ asyncio.run(main())

For more detailed examples, see [Bluetooth for Vehicles](docs/bluetooth_vehicles.md).

BLE connect and GATT write failures from `VehicleBluetooth` raise
`BluetoothTransportError`, a `TeslaFleetError` subclass, with the original
`bleak.exc.BleakError` chained as `__cause__`. Catch `TeslaFleetError` to
handle Bluetooth transport failures and `BluetoothTimeout` response-wait
timeouts through the same library error hierarchy.

### Routing and Failover

The `Router` class composes an ordered list of two-or-more backends that share a common method surface and dispatches each method call down the chain, automatically failing over on error. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise:
Expand Down
12 changes: 11 additions & 1 deletion docs/bluetooth_vehicles.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ You can wake up a `VehicleBluetooth` instance using the `wake_up` method. Here's
```python
import asyncio
from tesla_fleet_api import TeslaBluetooth
from tesla_fleet_api.exceptions import BluetoothTimeout
from tesla_fleet_api.exceptions import BluetoothTimeout, TeslaFleetError

async def main():
tesla_bluetooth = TeslaBluetooth()
Expand All @@ -76,6 +76,8 @@ async def main():
await vehicle.wake_up()
except BluetoothTimeout:
pass
except TeslaFleetError as e:
print(e)
print(f"Sent wake request to VehicleBluetooth instance for VIN: {vehicle.vin}")

asyncio.run(main())
Expand All @@ -89,6 +91,14 @@ the vehicle-security computer, so INFO-domain reads immediately after waking
should retry `BluetoothTimeout` with backoff. Keep one BLE connection open
across related commands when possible instead of reconnecting for each command.

`VehicleBluetooth` raises `BluetoothTransportError`, a `TeslaFleetError`
subclass, when the BLE connection or GATT command write fails before a vehicle
response can be awaited. The original `bleak.exc.BleakError` is available as
the exception's `__cause__`. Catch `TeslaFleetError` to handle both transport
failures and response-wait `BluetoothTimeout` failures with one library error
hierarchy, or catch `BluetoothTransportError` separately when you need to
distinguish a transport failure from a vehicle timeout.

## Climate Commands

Bluetooth vehicles support the same signed climate command methods as
Expand Down
6 changes: 6 additions & 0 deletions tesla_fleet_api.egg-info/PKG-INFO
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ asyncio.run(main())

For more detailed examples, see [Bluetooth for Vehicles](docs/bluetooth_vehicles.md).

BLE connect and GATT write failures from `VehicleBluetooth` raise
`BluetoothTransportError`, a `TeslaFleetError` subclass, with the original
`bleak.exc.BleakError` chained as `__cause__`. Catch `TeslaFleetError` to
handle Bluetooth transport failures and `BluetoothTimeout` response-wait
timeouts through the same library error hierarchy.

### Routing and Failover

The `Router` class composes an ordered list of two-or-more backends that share a common method surface and dispatches each method call down the chain, automatically failing over on error. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise:
Expand Down
8 changes: 8 additions & 0 deletions tesla_fleet_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ class BluetoothTimeout(TeslaFleetError):
message = "Bluetooth command timed out waiting for vehicle response."


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

message = (
"The Bluetooth transport failed before a vehicle response could be awaited."
)


class ResponseError(TeslaFleetError):
"""The response from the server was not JSON."""

Expand Down
36 changes: 25 additions & 11 deletions tesla_fleet_api/tesla/vehicle/bluetooth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from bleak import BleakClient, BleakScanner
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.device import BLEDevice
from bleak.exc import BleakError
from bleak_retry_connector import MAX_CONNECT_ATTEMPTS, establish_connection
from cryptography.hazmat.primitives.asymmetric import ec
from google.protobuf.message import DecodeError
Expand All @@ -17,6 +18,7 @@
from tesla_fleet_api.exceptions import (
WHITELIST_OPERATION_STATUS,
BluetoothTimeout,
BluetoothTransportError,
WhitelistOperationStatus,
)
from tesla_fleet_api.tesla.vehicle.commands import Commands
Expand Down Expand Up @@ -174,7 +176,13 @@ def discard_packet(self):


class VehicleBluetooth(Commands[BluetoothParentT], Generic[BluetoothParentT]):
"""Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing."""
"""Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing.

Callers can catch failures from this class with a single ``TeslaFleetError``:
connect/write transport failures surface as ``BluetoothTransportError`` and
a response-wait timeout as ``BluetoothTimeout``, both ``TeslaFleetError``
subclasses with the original transport exception chained as their cause.
"""

ble_name: str
device: BLEDevice | None = None
Expand Down Expand Up @@ -239,15 +247,18 @@ async def connect(self, max_attempts: int = MAX_CONNECT_ATTEMPTS) -> None:
"""Connect to the Tesla BLE device."""
if not self.device:
raise ValueError(f"BLEDevice {self.ble_name} has not been found or set")
self.client = await establish_connection(
BleakClient,
self.device,
self.vin,
max_attempts=max_attempts,
# ble_device_callback=self.get_device,
services=[SERVICE_UUID],
)
await self.client.start_notify(READ_UUID, self._on_notify)
try:
self.client = await establish_connection(
BleakClient,
self.device,
self.vin,
max_attempts=max_attempts,
# ble_device_callback=self.get_device,
services=[SERVICE_UUID],
)
await self.client.start_notify(READ_UUID, self._on_notify)
except BleakError as e:
raise BluetoothTransportError from e

async def disconnect(self) -> bool:
"""Disconnect from the Tesla BLE device."""
Expand Down Expand Up @@ -322,7 +333,10 @@ async def _send(

await self.connect_if_needed()
assert self.client is not None
await self.client.write_gatt_char(WRITE_UUID, payload, True)
try:
await self.client.write_gatt_char(WRITE_UUID, payload, True)
except BleakError as e:
raise BluetoothTransportError from e

# Process the response
try:
Expand Down
56 changes: 54 additions & 2 deletions tests/test_ble_send_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
from __future__ import annotations

from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch

from bleak.exc import BleakError
from cryptography.hazmat.primitives.asymmetric import ec

from tesla_fleet_api.exceptions import BluetoothTimeout
from tesla_fleet_api.exceptions import BluetoothTimeout, BluetoothTransportError
from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth
from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import (
Destination,
Expand Down Expand Up @@ -158,3 +159,54 @@ async def test_no_response_raises_bluetooth_timeout(self) -> None:

with self.assertRaises(BluetoothTimeout):
await vehicle._send(msg, "protobuf_message_as_bytes", timeout=0.05)


class SendTransportErrorTests(IsolatedAsyncioTestCase):
async def test_mid_write_gatt_failure_raises_bluetooth_transport_error(
self,
) -> None:
vehicle = _make_vehicle()
msg = _outgoing()
underlying = BleakError("write failed")
vehicle.client.write_gatt_char = AsyncMock(side_effect=underlying)

with self.assertRaises(BluetoothTransportError) as ctx:
await vehicle._send(msg, "protobuf_message_as_bytes")

self.assertIs(ctx.exception.__cause__, underlying)


class ConnectTransportErrorTests(IsolatedAsyncioTestCase):
async def test_establish_connection_failure_raises_bluetooth_transport_error(
self,
) -> None:
parent = MagicMock()
parent.private_key = ec.generate_private_key(ec.SECP256R1())
vehicle = VehicleBluetooth(parent, VIN)
vehicle.device = MagicMock()
underlying = BleakError("connect failed")

with patch(
"tesla_fleet_api.tesla.vehicle.bluetooth.establish_connection",
AsyncMock(side_effect=underlying),
):
with self.assertRaises(BluetoothTransportError) as ctx:
await vehicle.connect()

self.assertIs(ctx.exception.__cause__, underlying)

async def test_connect_if_needed_propagates_transport_error(self) -> None:
parent = MagicMock()
parent.private_key = ec.generate_private_key(ec.SECP256R1())
vehicle = VehicleBluetooth(parent, VIN)
vehicle.device = MagicMock()
underlying = BleakError("connect failed")

with patch(
"tesla_fleet_api.tesla.vehicle.bluetooth.establish_connection",
AsyncMock(side_effect=underlying),
):
with self.assertRaises(BluetoothTransportError) as ctx:
await vehicle.connect_if_needed()

self.assertIs(ctx.exception.__cause__, underlying)
Loading