From 0bcec2b98bebb5aca398e41eeb67fe03b2bcfa38 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 6 Aug 2026 11:00:43 +1000 Subject: [PATCH 1/2] fix(ble): cut connect retry budget from 4 attempts (~81s) to 2 (~40s) bleak_retry_connector's default max_attempts (4) pairs with its fixed, unexposed ~20s per-attempt connect timeout, so a contended connection slot burns ~81s before BluetoothTransportError lets a Router fail over to cloud - indistinguishable from a hang. DEFAULT_CONNECT_ATTEMPTS (2) still allows one retry for a genuinely transient failure (car waking, weak RF) while capping the worst case at ~40s; callers can still pass a larger max_attempts explicitly. --- AGENTS.md | 1 + tesla_fleet_api/tesla/vehicle/bluetooth.py | 16 ++- tests/test_ble_connect_retry_budget.py | 154 +++++++++++++++++++++ 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 tests/test_ble_connect_retry_budget.py diff --git a/AGENTS.md b/AGENTS.md index 54aa978..11e0213 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,7 @@ Keep the `tesla-protocol` floor at `>=0.5.0`; earlier releases have generated `. - **`navigation_gps_request`'s `order` param is a raw int, not a callable enum**: `commands.py` used to build it as `NavigationGpsRequest.RemoteNavTripOrder(order)`, treating the protobuf nested-enum wrapper (`EnumTypeWrapper`) as if it were a callable Python `enum.IntEnum` class - it isn't, so every call raised `TypeError` before any message was sent (found live during PR-8; this method had never been exercised over BLE before). Fixed to pass `order=order` directly (matching the working sibling `navigation_gps_destination_request`), which protobuf accepts as a bare int for an enum field at runtime. - **`ReassemblingBuffer` resets on a >1s inter-chunk gap, not just on decode failure**: `bluetooth.py`'s `ReassemblingBuffer.receive_data` discards any in-progress partial frame if the next chunk arrives more than `STALE_CHUNK_TIMEOUT` (1s) after the previous one, mirroring Tesla's official Go SDK (`teslamotors/vehicle-command`, `pkg/connector/ble/ble.go`'s `rxTimeout`). Without this, a chunk dropped mid-message left a stale partial in the buffer that got prepended to the next message, corrupting it until a lucky decode failure resynced. This is a frame-integrity hardening, not a fix for the separate ack-loss behavior documented above (that's a stalled/silent link, which no buffer-side reset can recover). - **`pair()` confirms whitelisting two ways: one-shot reply OR verify-by-state poll**: the whitelist-op success is a single VCSEC frame, and it lands on a dead session (lost forever) if the BLE link cycles while the user walks to the car to approve - live-observed on HA/macOS CoreBluetooth, where `tesla_fleet_api` logged "Reconnecting to S..." every ~100s during a pending pair, and the plain one-shot wait hung despite car-side completion. `pair()` (`bluetooth.py`) keeps the reply as the fast path (waits one `poll_interval` for it) but, on a lost reply, polls `_pair_probe()` every `poll_interval` until an overall `timeout` (default 300s) elapses. The probe is a VCSEC `_handshake` with our own public key: it succeeds only once the key is whitelisted and faults `NotOnWhitelistFault` until then - `_pair_probe` maps any `TeslaFleetError` (incl. transport failures from a mid-wait reconnect) to "not yet", so polling survives reconnects. The whitelist op is written **exactly once** - never re-sent - because a re-send re-prompts the user (and the retry/double-execute hazard documented above applies). Deadline with neither path confirming raises a typed `BluetoothTimeout`. Default behavior, no new knob (`poll_interval` is a defaulted param). +- **BLE connect retry budget (`DEFAULT_CONNECT_ATTEMPTS`)**: `connect()`/`connect_if_needed()` (`bluetooth.py`) default `max_attempts` to `DEFAULT_CONNECT_ATTEMPTS` (2), not `bleak_retry_connector`'s own `MAX_CONNECT_ATTEMPTS` (4) - live-verified: a contended connection slot (every phone/watch slot held) makes every GATT connect attempt genuinely time out at `bleak_retry_connector`'s fixed, unexposed per-attempt `BLEAK_TIMEOUT` (20s), so 4 attempts burned ~81s before `BluetoothTransportError` let a `Router` fail over to cloud - indistinguishable from a hang. That per-attempt timeout is hardcoded inside the vendored `bleak_retry_connector` package (not a parameter of `establish_connection`), so the only budget lever this library controls is attempt count; monkeypatching the dependency's module-level timeout constant was rejected since it would mutate global state shared with any other consumer in the same process (e.g. Home Assistant's other integrations). 2 attempts still gives one retry for a genuinely transient failure (car waking, weak RF) while capping the worst case at ~40s. A caller can still pass a larger `max_attempts` explicitly. - **Idle BLE keepalive (`keepalive_interval`)**: an idle held BLE link to the vehicle drops at ~42s mean lifetime (link supervision timeout ~720ms underneath, bluetoothd-verified on macOS CoreBluetooth); a single trivial passive GATT read every ~20s extends lifetime ~10x (~400s observed). `VehicleBluetooth.__init__`'s `keepalive_interval` (default `DEFAULT_KEEPALIVE_INTERVAL` = 20.0, `None`/`0` disables; threaded through `Vehicles`/`VehiclesBluetooth.create*`) starts one asyncio task per connection (`_keepalive_loop`, `bluetooth.py`) that reads `VERSION_UUID` only after `keepalive_interval` seconds of genuine GATT idleness. **Idle-triggered, not periodic**: `_last_activity` is bumped on every `_send` write and every `_on_notify` frame, so an active session never gets extra traffic; the loop recomputes the wait each pass and fires only when idle. The read is **bounded** (`_keepalive_timeout`, 2s) and **best-effort** - a prior un-timed RSSI read hung indefinitely against a sleeping car, so every attempt carries a timeout and swallows all failures (`_keepalive_read` catches `Exception`, never `CancelledError`); a failed keepalive never raises into user code, never triggers reconnect (the existing `connect_if_needed` machinery owns recovery), and never wakes the car. Task lifecycle is tied to the connection: started at the end of `connect()` (after `start_notify`), cancelled-and-awaited in `disconnect()` and restarted cleanly on reconnect (`_start_keepalive`/`_stop_keepalive`). **Sleep tradeoff**: these reads keep an *awake* car awake and defer vehicle sleep - consumers wanting the car to sleep should disable keepalive or disconnect when idle. Tests in `tests/test_ble_keepalive.py`. - **Cross-transport parity (cloud REST `VehicleFleet` vs BLE `Commands`)**: the same-named command on both paths should build a semantically equivalent instruction from identical args - a divergence there is a bug, but response *bodies* legitimately differ (REST JSON dict vs decoded protobuf) and are not. `tests/test_cross_transport_parity.py` locks the equivalence in with mocked-both-transports tests. Known **non-bug FORM differences** (do not "fix"): `set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` (no proto fields), `window_control` lat/lon and `navigation_sc_request` `id` (no proto fields), `navigation_request`'s `type`/`locale`/`timestamp_ms` (REST share-intent framing), and `media_volume_up` (no Tesla REST endpoint - BLE-only; cloud raises volume via `adjust_volume`), and (newly accepted) `clear_pin_to_drive_admin`'s `pin` param (no proto field on `VehicleControlResetPinToDriveAdminAction` - cloud still sends it in the REST body, BLE ignores it). `clear_pin_to_drive_admin`'s prior mismapping is fixed and live-verified: it now builds `VehicleControlResetPinToDriveAdminAction` (delegating to `reset_pin_to_drive_admin`), not the wrong `DrivingClearSpeedLimitPinAction` (speed-limit PIN, a different feature) it built before - the vehicle itself rejected a live call to the old build with reason `speed_limit_mode_active`, meaningful only to Speed Limit Mode, confirming the mismapping. `navigation_gps_request`'s prior `order` signature mismatch is resolved: both transports now default `order` to `0` (`REMOTE_NAV_TRIP_ORDER_UNKNOWN`, a defined proto enum value) when the caller omits it, so cloud always sends an explicit `order` rather than `null`. - **Per-command debug logging chokepoints and the `command=` name it derives**: `LOGGER.debug` lines of the form `command= transport= result=...` are emitted from exactly four places, not per-method - `Commands._sendVehicleSecurity`/`_getVehicleSecurity`/`_sendInfotainment`/`_getInfotainment` (`commands.py`, covers both BLE and Fleet-signed) and `TeslaFleetApi._request` (`fleet.py`, covers Fleet/Teslemetry/Tessie REST). `transport` comes from a `_transport_name` `ClassVar` set per concrete class (`"bluetooth"`/`"fleet"`/`"teslemetry"`/`"tessie"`), mirroring the existing `_auth_method` pattern - add that ClassVar to any new `Commands`/`TeslaFleetApi` subclass. For BLE/Fleet-signed, `command` is **not** the Python method name; it's derived from the populated protobuf oneof field (`vcsec_command_name`/`infotainment_command_name` in `commands.py`), e.g. `door_lock()` logs as `RKE_ACTION_LOCK` and `set_charge_limit()` as `chargingSetLimitAction` - deliberately robust to call-site changes since it reads the message being sent, not the call stack. `VehicleBluetooth`'s `verify_commands` resolution logs a second, separate line (`verify_commands=resolved`/`unresolved`) rather than duplicating the base class's raw-attempt line. `Router._dispatch` (`router/base.py`) logs `command=... backend= result=...` per backend tried, independent of the above. See `docs/bluetooth_vehicles.md`'s "Troubleshooting: Enable Debug Logging" section for the user-facing format; `tests/test_command_logging.py` locks in the exact line shapes. diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index 1b01601..500df7e 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -13,7 +13,7 @@ from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice from bleak.exc import BleakCharacteristicNotFoundError, BleakError -from bleak_retry_connector import MAX_CONNECT_ATTEMPTS, establish_connection +from bleak_retry_connector import establish_connection from cryptography.hazmat.primitives.asymmetric import ec from google.protobuf.message import DecodeError @@ -115,6 +115,14 @@ # every 20s keeps it alive ~10x longer. See AGENTS.md for the measured evidence. DEFAULT_KEEPALIVE_INTERVAL = 20.0 +# bleak_retry_connector's own default (4 attempts) pairs with its fixed ~20s +# per-attempt connect timeout to burn ~81s before a contended connection slot +# (all phone/watch slots held) surfaces failure - indistinguishable from a +# hang, and far past the point a Router should have already failed over to +# cloud. Two attempts still allows one retry for a genuinely transient +# failure (car waking, weak RF) while capping the worst case at ~40s. +DEFAULT_CONNECT_ATTEMPTS = 2 + if TYPE_CHECKING: # Resolved dynamically as ``bleak.BleakClient``/``bleak.BleakScanner`` at # call time so habluetooth's late-installed multi-adapter wrappers win @@ -614,7 +622,7 @@ def get_device(self) -> BLEDevice | None: """Return the currently assigned BLE device, if one has been discovered.""" return self.device - async def connect(self, max_attempts: int = MAX_CONNECT_ATTEMPTS) -> None: + async def connect(self, max_attempts: int = DEFAULT_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") @@ -700,7 +708,9 @@ def _on_ble_disconnected(self, client: BleakClient) -> None: return self._set_connected(False) - async def connect_if_needed(self, max_attempts: int = MAX_CONNECT_ATTEMPTS) -> None: + async def connect_if_needed( + self, max_attempts: int = DEFAULT_CONNECT_ATTEMPTS + ) -> None: """Connect to the Tesla BLE device if not already connected.""" async with self._connect_lock: if not self.client or not self.client.is_connected: diff --git a/tests/test_ble_connect_retry_budget.py b/tests/test_ble_connect_retry_budget.py new file mode 100644 index 0000000..6412a10 --- /dev/null +++ b/tests/test_ble_connect_retry_budget.py @@ -0,0 +1,154 @@ +"""Regression tests for the BLE connect retry budget. + +``bleak_retry_connector``'s own default (``MAX_CONNECT_ATTEMPTS`` = 4) pairs +with its fixed ~20s per-attempt connect timeout, so a contended connection +slot (every phone/watch slot held) burns ~81s of real GATT connect attempts +before ``connect()`` finally raises ``BluetoothTransportError`` and a +``Router`` can fail over to cloud - indistinguishable from a hang. These +tests lock in that ``connect()``/``connect_if_needed()`` now default to a +smaller attempt budget (``DEFAULT_CONNECT_ATTEMPTS``) instead of +``bleak_retry_connector``'s own default, while still letting a caller pass a +larger ``max_attempts`` explicitly for a scenario that genuinely needs it. +""" + +from __future__ import annotations + +from typing import Any +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock, patch + +from bleak.exc import BleakError +from cryptography.hazmat.primitives.asymmetric import ec + +import tesla_fleet_api.tesla.vehicle.bluetooth as vehicle_bluetooth +from tesla_fleet_api.exceptions import BluetoothTransportError +from tesla_fleet_api.router import VehicleRouter +from tesla_fleet_api.tesla.vehicle.bluetooth import ( + DEFAULT_CONNECT_ATTEMPTS, + VehicleBluetooth, +) + +VIN = "5YJXCAE43LF123456" + + +def _make_vehicle() -> VehicleBluetooth[Any]: + parent = MagicMock() + parent.private_key = ec.generate_private_key(ec.SECP256R1()) + vehicle = VehicleBluetooth(parent, VIN) + vehicle.device = MagicMock() + vehicle._start_keepalive = AsyncMock() # type: ignore[method-assign] + return vehicle + + +def _make_connected_client() -> MagicMock: + client = MagicMock() + client.start_notify = AsyncMock() + client.disconnect = AsyncMock() + client.is_connected = True + return client + + +class ConnectRetryBudgetTests(IsolatedAsyncioTestCase): + """The default attempt budget must be cut, not left at the vendored 4.""" + + def test_default_is_smaller_than_bleak_retry_connectors_own_default( + self, + ) -> None: + from bleak_retry_connector import MAX_CONNECT_ATTEMPTS + + self.assertLess(DEFAULT_CONNECT_ATTEMPTS, MAX_CONNECT_ATTEMPTS) + # Still allows one retry - a bare single attempt would give a + # genuinely transient failure (car waking, weak RF) no second try. + self.assertGreaterEqual(DEFAULT_CONNECT_ATTEMPTS, 2) + + async def test_connect_passes_reduced_default_to_establish_connection( + self, + ) -> None: + vehicle = _make_vehicle() + establish = AsyncMock(return_value=_make_connected_client()) + + with patch.object(vehicle_bluetooth, "establish_connection", establish): + await vehicle.connect() + + self.assertEqual( + establish.call_args.kwargs["max_attempts"], DEFAULT_CONNECT_ATTEMPTS + ) + + async def test_connect_if_needed_passes_reduced_default(self) -> None: + vehicle = _make_vehicle() + establish = AsyncMock(return_value=_make_connected_client()) + + with patch.object(vehicle_bluetooth, "establish_connection", establish): + await vehicle.connect_if_needed() + + self.assertEqual( + establish.call_args.kwargs["max_attempts"], DEFAULT_CONNECT_ATTEMPTS + ) + + async def test_caller_can_still_override_for_a_larger_budget(self) -> None: + """A caller doing its own long-poll retry can still ask for more.""" + vehicle = _make_vehicle() + establish = AsyncMock(return_value=_make_connected_client()) + + with patch.object(vehicle_bluetooth, "establish_connection", establish): + await vehicle.connect(max_attempts=5) + + self.assertEqual(establish.call_args.kwargs["max_attempts"], 5) + + async def test_contended_slot_failure_surfaces_after_the_reduced_budget( + self, + ) -> None: + """A slot-exhausted vehicle (every attempt in the budget times out) + must still raise ``BluetoothTransportError`` - only the budget + handed to ``establish_connection`` shrinks, not the exception + contract a ``Router`` fails over on.""" + vehicle = _make_vehicle() + # bleak_retry_connector exhausts the whole budget internally and + # raises a single BleakError once max_attempts is used up. + establish = AsyncMock( + side_effect=BleakError("device not found: out of connection slots") + ) + + with patch.object(vehicle_bluetooth, "establish_connection", establish): + with self.assertRaises(BluetoothTransportError): + await vehicle.connect() + + establish.assert_awaited_once() + self.assertEqual( + establish.call_args.kwargs["max_attempts"], DEFAULT_CONNECT_ATTEMPTS + ) + + +class _FakeCloudFallback: + """A cloud secondary tracking whether the router fell over to it.""" + + def __init__(self) -> None: + self.vin = VIN + self.wake_up_calls = 0 + + async def wake_up(self) -> dict[str, Any]: + self.wake_up_calls += 1 + return {"response": {"result": True, "reason": ""}} + + +class ContendedSlotFailsOverFastTests(IsolatedAsyncioTestCase): + """A contended-slot connect failure must still fail over to cloud - the + reduced budget only changes how long that takes, not whether it works.""" + + async def test_router_fails_over_after_reduced_connect_budget(self) -> None: + primary = _make_vehicle() + establish = AsyncMock( + side_effect=BleakError("device not found: out of connection slots") + ) + fallback = _FakeCloudFallback() + router = VehicleRouter(primary, fallback) + + with patch.object(vehicle_bluetooth, "establish_connection", establish): + result = await router.wake_up() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + self.assertEqual(fallback.wake_up_calls, 1) + # Only one establish_connection call for the whole failed primary + # attempt - the reduced max_attempts is what bounds its internal + # retry loop, not repeated calls from our code. + establish.assert_awaited_once() From 582a93e7d0be8d84effa7c62e02b001afa2edde5 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 6 Aug 2026 11:05:32 +1000 Subject: [PATCH 2/2] no-mistakes(document): Document reduced BLE connection retry budget --- AGENTS.md | 1 - docs/bluetooth_vehicles.md | 18 ++++++++++++++++++ tesla_fleet_api/tesla/vehicle/bluetooth.py | 8 ++------ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11e0213..54aa978 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,6 @@ Keep the `tesla-protocol` floor at `>=0.5.0`; earlier releases have generated `. - **`navigation_gps_request`'s `order` param is a raw int, not a callable enum**: `commands.py` used to build it as `NavigationGpsRequest.RemoteNavTripOrder(order)`, treating the protobuf nested-enum wrapper (`EnumTypeWrapper`) as if it were a callable Python `enum.IntEnum` class - it isn't, so every call raised `TypeError` before any message was sent (found live during PR-8; this method had never been exercised over BLE before). Fixed to pass `order=order` directly (matching the working sibling `navigation_gps_destination_request`), which protobuf accepts as a bare int for an enum field at runtime. - **`ReassemblingBuffer` resets on a >1s inter-chunk gap, not just on decode failure**: `bluetooth.py`'s `ReassemblingBuffer.receive_data` discards any in-progress partial frame if the next chunk arrives more than `STALE_CHUNK_TIMEOUT` (1s) after the previous one, mirroring Tesla's official Go SDK (`teslamotors/vehicle-command`, `pkg/connector/ble/ble.go`'s `rxTimeout`). Without this, a chunk dropped mid-message left a stale partial in the buffer that got prepended to the next message, corrupting it until a lucky decode failure resynced. This is a frame-integrity hardening, not a fix for the separate ack-loss behavior documented above (that's a stalled/silent link, which no buffer-side reset can recover). - **`pair()` confirms whitelisting two ways: one-shot reply OR verify-by-state poll**: the whitelist-op success is a single VCSEC frame, and it lands on a dead session (lost forever) if the BLE link cycles while the user walks to the car to approve - live-observed on HA/macOS CoreBluetooth, where `tesla_fleet_api` logged "Reconnecting to S..." every ~100s during a pending pair, and the plain one-shot wait hung despite car-side completion. `pair()` (`bluetooth.py`) keeps the reply as the fast path (waits one `poll_interval` for it) but, on a lost reply, polls `_pair_probe()` every `poll_interval` until an overall `timeout` (default 300s) elapses. The probe is a VCSEC `_handshake` with our own public key: it succeeds only once the key is whitelisted and faults `NotOnWhitelistFault` until then - `_pair_probe` maps any `TeslaFleetError` (incl. transport failures from a mid-wait reconnect) to "not yet", so polling survives reconnects. The whitelist op is written **exactly once** - never re-sent - because a re-send re-prompts the user (and the retry/double-execute hazard documented above applies). Deadline with neither path confirming raises a typed `BluetoothTimeout`. Default behavior, no new knob (`poll_interval` is a defaulted param). -- **BLE connect retry budget (`DEFAULT_CONNECT_ATTEMPTS`)**: `connect()`/`connect_if_needed()` (`bluetooth.py`) default `max_attempts` to `DEFAULT_CONNECT_ATTEMPTS` (2), not `bleak_retry_connector`'s own `MAX_CONNECT_ATTEMPTS` (4) - live-verified: a contended connection slot (every phone/watch slot held) makes every GATT connect attempt genuinely time out at `bleak_retry_connector`'s fixed, unexposed per-attempt `BLEAK_TIMEOUT` (20s), so 4 attempts burned ~81s before `BluetoothTransportError` let a `Router` fail over to cloud - indistinguishable from a hang. That per-attempt timeout is hardcoded inside the vendored `bleak_retry_connector` package (not a parameter of `establish_connection`), so the only budget lever this library controls is attempt count; monkeypatching the dependency's module-level timeout constant was rejected since it would mutate global state shared with any other consumer in the same process (e.g. Home Assistant's other integrations). 2 attempts still gives one retry for a genuinely transient failure (car waking, weak RF) while capping the worst case at ~40s. A caller can still pass a larger `max_attempts` explicitly. - **Idle BLE keepalive (`keepalive_interval`)**: an idle held BLE link to the vehicle drops at ~42s mean lifetime (link supervision timeout ~720ms underneath, bluetoothd-verified on macOS CoreBluetooth); a single trivial passive GATT read every ~20s extends lifetime ~10x (~400s observed). `VehicleBluetooth.__init__`'s `keepalive_interval` (default `DEFAULT_KEEPALIVE_INTERVAL` = 20.0, `None`/`0` disables; threaded through `Vehicles`/`VehiclesBluetooth.create*`) starts one asyncio task per connection (`_keepalive_loop`, `bluetooth.py`) that reads `VERSION_UUID` only after `keepalive_interval` seconds of genuine GATT idleness. **Idle-triggered, not periodic**: `_last_activity` is bumped on every `_send` write and every `_on_notify` frame, so an active session never gets extra traffic; the loop recomputes the wait each pass and fires only when idle. The read is **bounded** (`_keepalive_timeout`, 2s) and **best-effort** - a prior un-timed RSSI read hung indefinitely against a sleeping car, so every attempt carries a timeout and swallows all failures (`_keepalive_read` catches `Exception`, never `CancelledError`); a failed keepalive never raises into user code, never triggers reconnect (the existing `connect_if_needed` machinery owns recovery), and never wakes the car. Task lifecycle is tied to the connection: started at the end of `connect()` (after `start_notify`), cancelled-and-awaited in `disconnect()` and restarted cleanly on reconnect (`_start_keepalive`/`_stop_keepalive`). **Sleep tradeoff**: these reads keep an *awake* car awake and defer vehicle sleep - consumers wanting the car to sleep should disable keepalive or disconnect when idle. Tests in `tests/test_ble_keepalive.py`. - **Cross-transport parity (cloud REST `VehicleFleet` vs BLE `Commands`)**: the same-named command on both paths should build a semantically equivalent instruction from identical args - a divergence there is a bug, but response *bodies* legitimately differ (REST JSON dict vs decoded protobuf) and are not. `tests/test_cross_transport_parity.py` locks the equivalence in with mocked-both-transports tests. Known **non-bug FORM differences** (do not "fix"): `set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` (no proto fields), `window_control` lat/lon and `navigation_sc_request` `id` (no proto fields), `navigation_request`'s `type`/`locale`/`timestamp_ms` (REST share-intent framing), and `media_volume_up` (no Tesla REST endpoint - BLE-only; cloud raises volume via `adjust_volume`), and (newly accepted) `clear_pin_to_drive_admin`'s `pin` param (no proto field on `VehicleControlResetPinToDriveAdminAction` - cloud still sends it in the REST body, BLE ignores it). `clear_pin_to_drive_admin`'s prior mismapping is fixed and live-verified: it now builds `VehicleControlResetPinToDriveAdminAction` (delegating to `reset_pin_to_drive_admin`), not the wrong `DrivingClearSpeedLimitPinAction` (speed-limit PIN, a different feature) it built before - the vehicle itself rejected a live call to the old build with reason `speed_limit_mode_active`, meaningful only to Speed Limit Mode, confirming the mismapping. `navigation_gps_request`'s prior `order` signature mismatch is resolved: both transports now default `order` to `0` (`REMOTE_NAV_TRIP_ORDER_UNKNOWN`, a defined proto enum value) when the caller omits it, so cloud always sends an explicit `order` rather than `null`. - **Per-command debug logging chokepoints and the `command=` name it derives**: `LOGGER.debug` lines of the form `command= transport= result=...` are emitted from exactly four places, not per-method - `Commands._sendVehicleSecurity`/`_getVehicleSecurity`/`_sendInfotainment`/`_getInfotainment` (`commands.py`, covers both BLE and Fleet-signed) and `TeslaFleetApi._request` (`fleet.py`, covers Fleet/Teslemetry/Tessie REST). `transport` comes from a `_transport_name` `ClassVar` set per concrete class (`"bluetooth"`/`"fleet"`/`"teslemetry"`/`"tessie"`), mirroring the existing `_auth_method` pattern - add that ClassVar to any new `Commands`/`TeslaFleetApi` subclass. For BLE/Fleet-signed, `command` is **not** the Python method name; it's derived from the populated protobuf oneof field (`vcsec_command_name`/`infotainment_command_name` in `commands.py`), e.g. `door_lock()` logs as `RKE_ACTION_LOCK` and `set_charge_limit()` as `chargingSetLimitAction` - deliberately robust to call-site changes since it reads the message being sent, not the call stack. `VehicleBluetooth`'s `verify_commands` resolution logs a second, separate line (`verify_commands=resolved`/`unresolved`) rather than duplicating the base class's raw-attempt line. `Router._dispatch` (`router/base.py`) logs `command=... backend= result=...` per backend tried, independent of the above. See `docs/bluetooth_vehicles.md`'s "Troubleshooting: Enable Debug Logging" section for the user-facing format; `tests/test_command_logging.py` locks in the exact line shapes. diff --git a/docs/bluetooth_vehicles.md b/docs/bluetooth_vehicles.md index 8e65dd1..b163edf 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -74,6 +74,24 @@ Tradeoff: because these reads generate link traffic, they keep an already-awake car awake and defer vehicle sleep. If you want the vehicle to sleep while idle, disable keepalive or disconnect when you have no work for it. +## Connection Retry Budget + +`connect()` and `connect_if_needed()` make at most two connection attempts by +default. This allows one retry for a waking vehicle or weak signal while +limiting a failed connection to roughly 40 seconds before raising +`BluetoothTransportError`. In particular, this lets a `VehicleRouter` move to +its cloud fallback promptly when the vehicle is discoverable but all of its BLE +connection slots are occupied. + +Pass `max_attempts` explicitly when an environment needs a larger retry budget: + +```python +await vehicle.connect(max_attempts=4) +``` + +The underlying connector's per-attempt timeout is fixed at about 20 seconds, so +increasing this value increases the worst-case connection delay accordingly. + ## Pair Vehicle You can pair a `VehicleBluetooth` instance using the `pair` method. Here's a basic example to pair a `VehicleBluetooth` instance: diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index 500df7e..5ec045d 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -115,12 +115,8 @@ # every 20s keeps it alive ~10x longer. See AGENTS.md for the measured evidence. DEFAULT_KEEPALIVE_INTERVAL = 20.0 -# bleak_retry_connector's own default (4 attempts) pairs with its fixed ~20s -# per-attempt connect timeout to burn ~81s before a contended connection slot -# (all phone/watch slots held) surfaces failure - indistinguishable from a -# hang, and far past the point a Router should have already failed over to -# cloud. Two attempts still allows one retry for a genuinely transient -# failure (car waking, weak RF) while capping the worst case at ~40s. +# The connector's per-attempt timeout is fixed and unexposed. Keep one retry for +# transient failures without delaying Router fallback for its full default. DEFAULT_CONNECT_ATTEMPTS = 2 if TYPE_CHECKING: