From 2008e0525a2b0f433b4602c9eeacb4176ad4f59a Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 16:10:34 +1000 Subject: [PATCH 1/2] fix(ble): drop scan-time service UUID filter from find_vehicle The vehicle advertises no 128-bit service UUID in its advertisement or scan response - only its VIN-derived name (in the scan response). SERVICE_UUID only exists as a GATT service after connecting. Filtering BleakScanner discovery by service_uuids therefore hides the vehicle entirely on a direct BlueZ adapter; this was masked in testing because an ESPHome bluetooth proxy doesn't enforce that filter the same way. Scan unfiltered and match by name instead, with active scanning required so the scan response (which carries the name) is captured. Post-connect GATT use of SERVICE_UUID is untouched. --- tesla_fleet_api/tesla/vehicle/bluetooth.py | 5 +- tests/test_find_vehicle_scan_filter.py | 61 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/test_find_vehicle_scan_filter.py diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index 0d259ad..78660e2 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -208,7 +208,10 @@ async def find_vehicle( """Find the Tesla BLE device.""" if scanner is None: - scanner = BleakScanner(service_uuids=[SERVICE_UUID]) + # No service_uuids filter: the vehicle advertises no service UUID + # (SERVICE_UUID is GATT-only, post-connect); active scan is needed + # since the name lives in the scan response, not the advertisement. + scanner = BleakScanner(scanning_mode="active") if address is not None: device = await scanner.find_device_by_address(address) diff --git a/tests/test_find_vehicle_scan_filter.py b/tests/test_find_vehicle_scan_filter.py new file mode 100644 index 0000000..88225e8 --- /dev/null +++ b/tests/test_find_vehicle_scan_filter.py @@ -0,0 +1,61 @@ +"""Regression test: find_vehicle must not scan-filter by service UUID. + +The vehicle advertises no 128-bit service UUID (SERVICE_UUID is GATT-only, +available only after connecting); it only advertises its VIN-derived name in +the scan response. A service_uuids scan filter hides the vehicle entirely on +a direct BlueZ adapter. +""" + +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock, patch + +from cryptography.hazmat.primitives.asymmetric import ec + +from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth + + +class _ConcreteVehicleBluetooth(VehicleBluetooth): + async def _send(self, msg, requires): # pragma: no cover - not reached + raise AssertionError("_send should not be called in this test") + + +class FindVehicleScanFilterTests(IsolatedAsyncioTestCase): + VIN = "5YJXCAE43LF123456" + + def _make_vehicle(self) -> _ConcreteVehicleBluetooth: + parent = MagicMock() + key = ec.generate_private_key(ec.SECP256R1()) + return _ConcreteVehicleBluetooth(parent, self.VIN, key) + + async def test_default_scanner_has_no_service_uuid_filter(self): + vehicle = self._make_vehicle() + + fake_device = MagicMock() + captured_kwargs = {} + + class FakeScanner: + def __init__(self, *args, **kwargs): + captured_kwargs.update(kwargs) + + async def find_device_by_name(self, name): + return fake_device + + with patch("tesla_fleet_api.tesla.vehicle.bluetooth.BleakScanner", FakeScanner): + device = await vehicle.find_vehicle() + + self.assertIs(device, fake_device) + self.assertNotIn("service_uuids", captured_kwargs) + self.assertEqual(captured_kwargs.get("scanning_mode"), "active") + + async def test_matches_by_vin_derived_name_without_service_uuid(self): + vehicle = self._make_vehicle() + + fake_device = MagicMock() + find_device_by_name = AsyncMock(return_value=fake_device) + scanner = MagicMock() + scanner.find_device_by_name = find_device_by_name + + device = await vehicle.find_vehicle(scanner=scanner) + + find_device_by_name.assert_awaited_once_with(vehicle.ble_name) + self.assertIs(device, fake_device) From 9a6c1b6d95b11690a7e9b22a75bbb7538ac5d8f5 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 16:11:52 +1000 Subject: [PATCH 2/2] docs: record BLE discovery scan-filter gotcha in AGENTS.md Durable knowledge from the find_vehicle scan-filter fix: the vehicle advertises no service UUID pre-connect, so a BleakScanner service_uuids filter hides it on a direct BlueZ adapter. --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d16ee53..5b83564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,3 +107,11 @@ Generated protobuf files live in `tesla/vehicle/proto/` and are excluded from ru - **Enums**: Custom `StrEnum`/`IntEnum` in `const.py` (not stdlib). `Region` is a `Literal["na", "eu", "cn"]`, not an enum. - **Seat indexing gotcha**: two distinct seat enums with different conventions. `Seat` is **0-indexed** (`FRONT_LEFT=0`) and is for the manual seat heater/cooler paths (`remote_seat_heater_request`, `remote_seat_cooler_request`). `AutoSeat` is **1-indexed** (`FRONT_LEFT=1`, `FRONT_RIGHT=2`) and is the correct type for `remote_auto_seat_climate_request` on **both** backends — its values equal Tesla's REST wire values and the proto `AutoSeatPosition_*` enum. Don't mix them; passing a `Seat` to the auto-climate command is off-by-one (issue #11). - **Naming**: camelCase for class instance attributes that mirror API structure (`energySites`, `createFleet`). Snake_case for method names that are API endpoints. +- **BLE discovery gotcha**: a Tesla vehicle advertises no 128-bit service UUID pre-connect — only its VIN-derived local name (`^S[a-f0-9]{16}[CDRP]$`), and only in the scan response, not the `ADV_IND`. `SERVICE_UUID` (`tesla_fleet_api/tesla/vehicle/bluetooth.py`) exists only as a GATT service after connecting. Never pass `service_uuids=[SERVICE_UUID]` as a `BleakScanner` discovery-time filter — it hides the vehicle on a direct BlueZ adapter (an ESPHome proxy doesn't enforce that filter the same way, which can mask the bug in testing). Scan unfiltered with active scanning and match by name; keep `SERVICE_UUID` for post-connect GATT use only. + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise.