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. 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)