Explain why Teslemetry Bluetooth pairing failed (#184172)

This commit is contained in:
Brett Adams
2026-10-05 13:28:33 +02:00
committed by GitHub
parent 8f854123fb
commit a7e35e9afa
3 changed files with 156 additions and 14 deletions
@@ -9,6 +9,8 @@ from typing import TYPE_CHECKING, Any, cast, override
from aiohttp import ClientError
from aiopowerwall import PowerwallAuthenticationError, PowerwallError
from bleak.exc import BleakError
from bleak_retry_connector import BleakNotFoundError, BleakOutOfConnectionSlotsError
from habluetooth.const import STRONG_OWNER_STALE_RSSI
import probatio
from tesla_fleet_api.const import (
AuthorizedClientKeyType,
@@ -42,6 +44,7 @@ from homeassistant.components.application_credentials import (
)
from homeassistant.components.bluetooth import (
async_discovered_service_info,
async_last_service_info,
async_request_active_scan,
async_scanner_count,
)
@@ -304,7 +307,27 @@ class VehicleSubentryFlowHandler(ConfigSubentryFlow):
except (BleakError, TeslaFleetError, TimeoutError) as err:
LOGGER.error("Failed to connect over Bluetooth: %s", err)
await self._async_disconnect()
errors["base"] = "cannot_connect"
cause = err.__cause__
last_info = async_last_service_info(
self.hass, device.address, connectable=True
)
if not isinstance(
cause, BleakNotFoundError | BleakOutOfConnectionSlotsError
):
errors["base"] = "cannot_connect"
# bleak-retry-connector also raises BleakNotFoundError from a final connect timeout.
elif last_info is None or (
isinstance(cause, BleakNotFoundError)
and not isinstance(cause.__cause__, TimeoutError)
):
errors["base"] = "device_not_found"
elif isinstance(cause, BleakOutOfConnectionSlotsError):
errors["base"] = "no_connection_slot"
# habluetooth treats this signal as a close device, so the timeout is not about range.
elif last_info.rssi >= STRONG_OWNER_STALE_RSSI:
errors["base"] = "vehicle_busy"
else:
errors["base"] = "weak_signal"
else:
return await self.async_step_pair()
@@ -105,10 +105,13 @@
"confirm_timeout": "The key was not confirmed on the vehicle's touchscreen in time. Select Submit, tap a key card, then confirm the prompt on the vehicle's touchscreen.",
"device_not_found": "No matching Tesla vehicle was found nearby over Bluetooth. Make sure the vehicle is awake and within range, then try again.",
"key_unverified": "The key was added, but the vehicle's reply could not be verified. Select Submit to try again.",
"no_connection_slot": "Every Bluetooth adapter or proxy that can reach the vehicle has no free connection slot. Free a connection slot on a Bluetooth proxy, or add another proxy near the vehicle, then try again.",
"pair_denied": "The key was declined on the vehicle's touchscreen. Select Submit and tap a key card to try again.",
"pair_failed": "The vehicle rejected the key. Make sure it is not in valet mode and does not already have the maximum number of keys, then try again.",
"tap_timeout": "The key card was not tapped in time. Select Submit and tap a key card within 30 seconds.",
"timeout": "Timed out waiting for the vehicle to add the key. Select Submit and tap a key card on the center console card reader."
"timeout": "Timed out waiting for the vehicle to add the key. Select Submit and tap a key card on the center console card reader.",
"vehicle_busy": "The vehicle is busy and may already have its maximum number of Bluetooth connections. Disconnect other phones or devices from the vehicle, then try again.",
"weak_signal": "The vehicle's Bluetooth signal is too weak to connect. Move the vehicle closer to a Bluetooth adapter, or add a Bluetooth proxy near it, then try again."
},
"initiate_flow": {
"user": "Add local vehicle"
+128 -12
View File
@@ -14,7 +14,12 @@ from aiopowerwall import (
PowerwallConnectionError,
PowerwallFaultError,
)
from bleak.exc import BleakError
from bleak.exc import BleakDeviceNotFoundError, BleakError
from bleak_retry_connector import (
BleakConnectionError,
BleakNotFoundError,
BleakOutOfConnectionSlotsError,
)
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import probatio
@@ -78,6 +83,11 @@ from . import mock_config_entry, setup_platform
from .const import CONFIG_V1, METADATA, PRODUCTS, UNIQUE_ID
from tests.common import MockConfigEntry
from tests.components.bluetooth import (
generate_advertisement_data,
generate_ble_device,
inject_advertisement,
)
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@@ -730,7 +740,7 @@ def _discovered_info() -> MagicMock:
info = MagicMock()
info.name = TeslaBluetooth().get_name(VIN)
info.address = ADDRESS
info.device = MagicMock()
info.device = generate_ble_device(ADDRESS, info.name)
return info
@@ -1003,12 +1013,23 @@ async def test_subentry_handshake_fails_after_pairing(hass: HomeAssistant) -> No
vehicle.pair.assert_awaited_once()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_subentry_scan_connect_fails(hass: HomeAssistant) -> None:
"""The scan step re-shows the form with an error when BLE connect fails."""
def _connect_failure(*chain: BaseException) -> BluetoothTransportError:
"""Return the connect error tesla_fleet_api raises, caused by the given chain."""
error = BluetoothTransportError()
outer: BaseException = error
for cause in chain:
outer.__cause__ = cause
outer = cause
return error
async def _connect_fails_then_pairs(
hass: HomeAssistant, error: BaseException, expected: str
) -> None:
"""Fail the first Bluetooth connect, check the error, then pair on retry."""
entry = await _setup_account_entry(hass)
vehicle = _mock_vehicle()
vehicle.connect = AsyncMock(side_effect=BleakError("nope"))
vehicle.connect = AsyncMock(side_effect=[error, None])
with (
patch(
@@ -1019,18 +1040,113 @@ async def test_subentry_scan_connect_fails(hass: HomeAssistant) -> None:
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
return_value=_mock_ble_parent(vehicle),
),
patch.object(hass.config_entries, "async_schedule_reload"),
):
result = await _start_pairing_at_scan(hass, entry)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "scan"
assert result["errors"] == {"base": expected}
# A failed pairing never creates a subentry.
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
vehicle.disconnect.assert_awaited_once()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "scan"
assert result["errors"] == {"base": "cannot_connect"}
# A failed pairing never creates a subentry.
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
vehicle.disconnect.assert_awaited_once()
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
assert len(subentries) == 1
assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
@pytest.mark.parametrize(
("error", "rssi", "expected"),
[
pytest.param(
_connect_failure(
BleakOutOfConnectionSlotsError("no slot"), BleakError("no slot")
),
-60,
"no_connection_slot",
id="out_of_slots",
),
pytest.param(
_connect_failure(BleakNotFoundError("timeout"), TimeoutError()),
-62,
"vehicle_busy",
id="timeout_strong_signal",
),
pytest.param(
_connect_failure(BleakNotFoundError("timeout"), TimeoutError()),
-70,
"vehicle_busy",
id="timeout_at_strong_threshold",
),
pytest.param(
_connect_failure(BleakNotFoundError("timeout"), TimeoutError()),
-71,
"weak_signal",
id="timeout_weak_signal",
),
pytest.param(
_connect_failure(
BleakNotFoundError("missing"), BleakDeviceNotFoundError(ADDRESS)
),
-60,
"device_not_found",
id="device_vanished",
),
pytest.param(
_connect_failure(BleakConnectionError("failed")),
-60,
"cannot_connect",
id="other_connection_error",
),
pytest.param(BleakError("nope"), -60, "cannot_connect", id="unwrapped_error"),
],
)
@pytest.mark.usefixtures("enable_bluetooth")
async def test_subentry_scan_connect_fails(
hass: HomeAssistant,
error: BleakError | BluetoothTransportError,
rssi: int,
expected: str,
) -> None:
"""The scan step explains why the Bluetooth connect failed, then pairs on retry."""
inject_advertisement(
hass,
generate_ble_device(ADDRESS, TeslaBluetooth().get_name(VIN)),
generate_advertisement_data(rssi=rssi),
)
await _connect_fails_then_pairs(hass, error, expected)
@pytest.mark.parametrize(
"error",
[
pytest.param(
_connect_failure(
BleakOutOfConnectionSlotsError("no slot"), BleakError("no slot")
),
id="out_of_slots",
),
pytest.param(
_connect_failure(BleakNotFoundError("timeout"), TimeoutError()),
id="timeout",
),
],
)
@pytest.mark.usefixtures("enable_bluetooth")
async def test_subentry_scan_connect_fails_vehicle_gone(
hass: HomeAssistant, error: BluetoothTransportError
) -> None:
"""A vehicle no longer advertising when the connect fails is reported not found."""
await _connect_fails_then_pairs(hass, error, "device_not_found")
@pytest.mark.parametrize(