diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 554cdb366850..9449841d7245 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -59,6 +59,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( + BLE_DISCONNECT_TIMEOUT, CLIENT_ID, CONF_VIN, DOMAIN, @@ -905,12 +906,23 @@ async def async_unload_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) for vehicle in entry.runtime_data.vehicles: if isinstance(vehicle.api, VehicleRouter): try: - await vehicle.api.primary.disconnect() - except (BleakError, TeslaFleetError, TimeoutError) as err: - # Swallowed so one stuck link cannot block the unload, but - # warn: a leaked BLE connection can keep the vehicle awake. + async with asyncio.timeout(BLE_DISCONNECT_TIMEOUT): + try: + await vehicle.api.primary.disconnect() + except (BleakError, TeslaFleetError, TimeoutError) as err: + # Swallowed so one stuck link cannot block the + # unload, but warn: a leaked BLE connection can + # keep the vehicle awake. + LOGGER.warning( + "Error disconnecting Bluetooth for %s: %s", + vehicle.vin, + err, + ) + except TimeoutError: LOGGER.warning( - "Error disconnecting Bluetooth for %s: %s", vehicle.vin, err + "Bluetooth disconnect for %s timed out after %ss", + vehicle.vin, + BLE_DISCONNECT_TIMEOUT, ) return unloaded diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index c94145d19a6c..6c79f967ed90 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -17,6 +17,7 @@ CONF_VIN = "vin" VEHICLE_KEY_FILE = "tesla_vehicle.key" BLE_PARENT_KEY = f"{DOMAIN}_ble_parent" BLE_PARENT_LOCK_KEY = f"{DOMAIN}_ble_parent_lock" +BLE_DISCONNECT_TIMEOUT = 10 SUBENTRY_TYPE_ENERGY_SITE = "energy_site" CONF_SITE_ID = "site_id" diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index f15858662ab5..d0a6ebd242ab 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -2319,6 +2319,82 @@ async def test_unload_never_connected_bluetooth(hass: HomeAssistant) -> None: bluetooth_vehicle.disconnect.assert_awaited_once() +async def test_unload_disconnect_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """A hung Bluetooth disconnect cannot block unload past the timeout.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + bluetooth_vehicle = AsyncMock() + never_set = asyncio.Event() + + async def _hang(*args: object, **kwargs: object) -> None: + await never_set.wait() + + bluetooth_vehicle.disconnect = AsyncMock(side_effect=_hang) + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + patch("homeassistant.components.teslemetry.BLE_DISCONNECT_TIMEOUT", 0), + caplog.at_level(logging.WARNING), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = ( + bluetooth_vehicle + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + bluetooth_vehicle.disconnect.assert_awaited_once() + assert "timed out after 0s" in caplog.text + + +async def test_unload_disconnect_instant_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """A TimeoutError raised by disconnect() itself is not mistaken for the deadline.""" + entry = _entry_with_ble() + entry.add_to_hass(hass) + bluetooth_vehicle = AsyncMock() + bluetooth_vehicle.disconnect = AsyncMock(side_effect=TimeoutError("device busy")) + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch("homeassistant.components.teslemetry.PLATFORMS", []), + caplog.at_level(logging.WARNING), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = ( + bluetooth_vehicle + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + bluetooth_vehicle.disconnect.assert_awaited_once() + assert "Error disconnecting Bluetooth for" in caplog.text + assert "device busy" in caplog.text + assert "timed out after" not in caplog.text + + async def test_ble_parent_shared_and_cached(hass: HomeAssistant) -> None: """The BLE parent (holding the private key) is created once and reused.""" with patch(