Skip to content

Commit 9ecb181

Browse files
Bre77firstmate crewmate
andauthored
fix(ble): wrap transport failures in library errors (#55)
* 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. * no-mistakes(document): Document BLE transport errors --------- Co-authored-by: firstmate crewmate <crewmate@firstmate.local>
1 parent 629c16d commit 9ecb181

7 files changed

Lines changed: 111 additions & 15 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ No release-please or version-bump automation. To ship: bump `version` in `pyproj
9999

100100
`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`.
101101

102-
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.
102+
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`.
103103

104104
### Protobuf
105105

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ asyncio.run(main())
166166

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

169+
BLE connect and GATT write failures from `VehicleBluetooth` raise
170+
`BluetoothTransportError`, a `TeslaFleetError` subclass, with the original
171+
`bleak.exc.BleakError` chained as `__cause__`. Catch `TeslaFleetError` to
172+
handle Bluetooth transport failures and `BluetoothTimeout` response-wait
173+
timeouts through the same library error hierarchy.
174+
169175
### Routing and Failover
170176

171177
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:

‎docs/bluetooth_vehicles.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ You can wake up a `VehicleBluetooth` instance using the `wake_up` method. Here's
6565
```python
6666
import asyncio
6767
from tesla_fleet_api import TeslaBluetooth
68-
from tesla_fleet_api.exceptions import BluetoothTimeout
68+
from tesla_fleet_api.exceptions import BluetoothTimeout, TeslaFleetError
6969

7070
async def main():
7171
tesla_bluetooth = TeslaBluetooth()
@@ -76,6 +76,8 @@ async def main():
7676
await vehicle.wake_up()
7777
except BluetoothTimeout:
7878
pass
79+
except TeslaFleetError as e:
80+
print(e)
7981
print(f"Sent wake request to VehicleBluetooth instance for VIN: {vehicle.vin}")
8082

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

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

94104
Bluetooth vehicles support the same signed climate command methods as

‎tesla_fleet_api.egg-info/PKG-INFO‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ asyncio.run(main())
189189

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

192+
BLE connect and GATT write failures from `VehicleBluetooth` raise
193+
`BluetoothTransportError`, a `TeslaFleetError` subclass, with the original
194+
`bleak.exc.BleakError` chained as `__cause__`. Catch `TeslaFleetError` to
195+
handle Bluetooth transport failures and `BluetoothTimeout` response-wait
196+
timeouts through the same library error hierarchy.
197+
192198
### Routing and Failover
193199

194200
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:

‎tesla_fleet_api/exceptions.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ class BluetoothTimeout(TeslaFleetError):
2828
message = "Bluetooth command timed out waiting for vehicle response."
2929

3030

31+
class BluetoothTransportError(TeslaFleetError):
32+
"""The Bluetooth transport (connect or GATT write) failed before a vehicle response could be awaited."""
33+
34+
message = (
35+
"The Bluetooth transport failed before a vehicle response could be awaited."
36+
)
37+
38+
3139
class ResponseError(TeslaFleetError):
3240
"""The response from the server was not JSON."""
3341

‎tesla_fleet_api/tesla/vehicle/bluetooth.py‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from bleak import BleakClient, BleakScanner
1010
from bleak.backends.characteristic import BleakGATTCharacteristic
1111
from bleak.backends.device import BLEDevice
12+
from bleak.exc import BleakError
1213
from bleak_retry_connector import MAX_CONNECT_ATTEMPTS, establish_connection
1314
from cryptography.hazmat.primitives.asymmetric import ec
1415
from google.protobuf.message import DecodeError
@@ -17,6 +18,7 @@
1718
from tesla_fleet_api.exceptions import (
1819
WHITELIST_OPERATION_STATUS,
1920
BluetoothTimeout,
21+
BluetoothTransportError,
2022
WhitelistOperationStatus,
2123
)
2224
from tesla_fleet_api.tesla.vehicle.commands import Commands
@@ -174,7 +176,13 @@ def discard_packet(self):
174176

175177

176178
class VehicleBluetooth(Commands[BluetoothParentT], Generic[BluetoothParentT]):
177-
"""Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing."""
179+
"""Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing.
180+
181+
Callers can catch failures from this class with a single ``TeslaFleetError``:
182+
connect/write transport failures surface as ``BluetoothTransportError`` and
183+
a response-wait timeout as ``BluetoothTimeout``, both ``TeslaFleetError``
184+
subclasses with the original transport exception chained as their cause.
185+
"""
178186

179187
ble_name: str
180188
device: BLEDevice | None = None
@@ -239,15 +247,18 @@ async def connect(self, max_attempts: int = MAX_CONNECT_ATTEMPTS) -> None:
239247
"""Connect to the Tesla BLE device."""
240248
if not self.device:
241249
raise ValueError(f"BLEDevice {self.ble_name} has not been found or set")
242-
self.client = await establish_connection(
243-
BleakClient,
244-
self.device,
245-
self.vin,
246-
max_attempts=max_attempts,
247-
# ble_device_callback=self.get_device,
248-
services=[SERVICE_UUID],
249-
)
250-
await self.client.start_notify(READ_UUID, self._on_notify)
250+
try:
251+
self.client = await establish_connection(
252+
BleakClient,
253+
self.device,
254+
self.vin,
255+
max_attempts=max_attempts,
256+
# ble_device_callback=self.get_device,
257+
services=[SERVICE_UUID],
258+
)
259+
await self.client.start_notify(READ_UUID, self._on_notify)
260+
except BleakError as e:
261+
raise BluetoothTransportError from e
251262

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

323334
await self.connect_if_needed()
324335
assert self.client is not None
325-
await self.client.write_gatt_char(WRITE_UUID, payload, True)
336+
try:
337+
await self.client.write_gatt_char(WRITE_UUID, payload, True)
338+
except BleakError as e:
339+
raise BluetoothTransportError from e
326340

327341
# Process the response
328342
try:

‎tests/test_ble_send_transport.py‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
from __future__ import annotations
1111

1212
from unittest import IsolatedAsyncioTestCase
13-
from unittest.mock import AsyncMock, MagicMock
13+
from unittest.mock import AsyncMock, MagicMock, patch
1414

15+
from bleak.exc import BleakError
1516
from cryptography.hazmat.primitives.asymmetric import ec
1617

17-
from tesla_fleet_api.exceptions import BluetoothTimeout
18+
from tesla_fleet_api.exceptions import BluetoothTimeout, BluetoothTransportError
1819
from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth
1920
from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import (
2021
Destination,
@@ -158,3 +159,54 @@ async def test_no_response_raises_bluetooth_timeout(self) -> None:
158159

159160
with self.assertRaises(BluetoothTimeout):
160161
await vehicle._send(msg, "protobuf_message_as_bytes", timeout=0.05)
162+
163+
164+
class SendTransportErrorTests(IsolatedAsyncioTestCase):
165+
async def test_mid_write_gatt_failure_raises_bluetooth_transport_error(
166+
self,
167+
) -> None:
168+
vehicle = _make_vehicle()
169+
msg = _outgoing()
170+
underlying = BleakError("write failed")
171+
vehicle.client.write_gatt_char = AsyncMock(side_effect=underlying)
172+
173+
with self.assertRaises(BluetoothTransportError) as ctx:
174+
await vehicle._send(msg, "protobuf_message_as_bytes")
175+
176+
self.assertIs(ctx.exception.__cause__, underlying)
177+
178+
179+
class ConnectTransportErrorTests(IsolatedAsyncioTestCase):
180+
async def test_establish_connection_failure_raises_bluetooth_transport_error(
181+
self,
182+
) -> None:
183+
parent = MagicMock()
184+
parent.private_key = ec.generate_private_key(ec.SECP256R1())
185+
vehicle = VehicleBluetooth(parent, VIN)
186+
vehicle.device = MagicMock()
187+
underlying = BleakError("connect failed")
188+
189+
with patch(
190+
"tesla_fleet_api.tesla.vehicle.bluetooth.establish_connection",
191+
AsyncMock(side_effect=underlying),
192+
):
193+
with self.assertRaises(BluetoothTransportError) as ctx:
194+
await vehicle.connect()
195+
196+
self.assertIs(ctx.exception.__cause__, underlying)
197+
198+
async def test_connect_if_needed_propagates_transport_error(self) -> None:
199+
parent = MagicMock()
200+
parent.private_key = ec.generate_private_key(ec.SECP256R1())
201+
vehicle = VehicleBluetooth(parent, VIN)
202+
vehicle.device = MagicMock()
203+
underlying = BleakError("connect failed")
204+
205+
with patch(
206+
"tesla_fleet_api.tesla.vehicle.bluetooth.establish_connection",
207+
AsyncMock(side_effect=underlying),
208+
):
209+
with self.assertRaises(BluetoothTransportError) as ctx:
210+
await vehicle.connect_if_needed()
211+
212+
self.assertIs(ctx.exception.__cause__, underlying)

0 commit comments

Comments
 (0)