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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion tesla_fleet_api/tesla/vehicle/bluetooth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions tests/test_find_vehicle_scan_filter.py
Original file line number Diff line number Diff line change
@@ -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)
Loading