From 218ff14dcf4eb5380ea94b014fab71ec723478fc Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Fri, 10 Jul 2026 07:50:20 +1000 Subject: [PATCH 1/2] fix(ble): wrap BLE transport failures in BluetoothTransportError connect_if_needed()/connect() and the mid-command GATT write in _send() previously let raw bleak.exc.BleakError escape uncaught, so a caller doing except TeslaFleetError missed transport failures entirely (only the response-wait timeout was wrapped, as BluetoothTimeout). Wrap both paths in a new BluetoothTransportError(TeslaFleetError), chaining the original BleakError as the cause, so VehicleBluetooth failures are catchable through one hierarchy. --- AGENTS.md | 2 +- tesla_fleet_api/exceptions.py | 8 ++++ tesla_fleet_api/tesla/vehicle/bluetooth.py | 36 +++++++++----- tests/test_ble_send_transport.py | 56 +++++++++++++++++++++- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c570d36..a9503dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/tesla_fleet_api/exceptions.py b/tesla_fleet_api/exceptions.py index ae96903..c627923 100644 --- a/tesla_fleet_api/exceptions.py +++ b/tesla_fleet_api/exceptions.py @@ -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.""" diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index 0712ea8..a5c2c6b 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -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 @@ -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 @@ -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 @@ -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.""" @@ -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: diff --git a/tests/test_ble_send_transport.py b/tests/test_ble_send_transport.py index df74a2a..da633e1 100644 --- a/tests/test_ble_send_transport.py +++ b/tests/test_ble_send_transport.py @@ -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, @@ -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) From e8fbf32f7d3e1ed1b6414a51b2b173deef0125b6 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Fri, 10 Jul 2026 07:56:49 +1000 Subject: [PATCH 2/2] no-mistakes(document): Document BLE transport errors --- README.md | 6 ++++++ docs/bluetooth_vehicles.md | 12 +++++++++++- tesla_fleet_api.egg-info/PKG-INFO | 6 ++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d901337..702fd3f 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/bluetooth_vehicles.md b/docs/bluetooth_vehicles.md index 9fc0c4a..679cbf3 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -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() @@ -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()) @@ -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 diff --git a/tesla_fleet_api.egg-info/PKG-INFO b/tesla_fleet_api.egg-info/PKG-INFO index be85d58..926f0e3 100644 --- a/tesla_fleet_api.egg-info/PKG-INFO +++ b/tesla_fleet_api.egg-info/PKG-INFO @@ -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: