diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 2077b05cdc5..a9a7406e5d6 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -1,19 +1,21 @@ """Tessie integration.""" import asyncio -from http import HTTPStatus import logging -from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, + GatewayTimeout, + InvalidResponse, InvalidToken, + MissingToken, + RateLimited, + ServiceUnavailable, SubscriptionRequired, TeslaFleetError, ) from tesla_fleet_api.tessie import Tessie -from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform @@ -54,57 +56,69 @@ _LOGGER = logging.getLogger(__name__) type TessieConfigEntry = ConfigEntry[TessieData] +RETRY_EXCEPTIONS = ( + InvalidResponse, + RateLimited, + ServiceUnavailable, + GatewayTimeout, +) + async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bool: """Set up Tessie config.""" api_key = entry.data[CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) + tessie = Tessie(session, api_key) try: - state_of_all_vehicles = await get_state_of_all_vehicles( - session=session, - api_key=api_key, - only_active=True, - ) - except ClientResponseError as e: - if e.status == HTTPStatus.UNAUTHORIZED: - raise ConfigEntryAuthFailed from e - raise ConfigEntryError("Setup failed, unable to connect to Tessie") from e - except ClientError as e: + state_of_all_vehicles = await tessie.list_vehicles(only_active=True) + except (InvalidToken, MissingToken) as e: + raise ConfigEntryAuthFailed from e + except RETRY_EXCEPTIONS as e: raise ConfigEntryNotReady from e + except TeslaFleetError as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e - vehicles = [ - TessieVehicleData( - vin=vehicle["vin"], - data_coordinator=TessieStateUpdateCoordinator( - hass, - entry, - api_key=api_key, - vin=vehicle["vin"], - data=vehicle["last_state"], - ), - device=DeviceInfo( - identifiers={(DOMAIN, vehicle["vin"])}, - manufacturer="Tesla", - configuration_url="https://my.tessie.com/", - name=vehicle["last_state"]["display_name"], - model=MODELS.get( - vehicle["last_state"]["vehicle_config"]["car_type"], - vehicle["last_state"]["vehicle_config"]["car_type"], + vehicles: list[TessieVehicleData] = [] + for vehicle in state_of_all_vehicles["results"]: + if vehicle["last_state"] is None: + continue + + vin = vehicle["vin"] + vehicle_api = tessie.vehicles.create(vin) + vehicles.append( + TessieVehicleData( + vin=vin, + data_coordinator=TessieStateUpdateCoordinator( + hass, + entry, + api=vehicle_api, + api_key=api_key, + vin=vin, + data=vehicle["last_state"], ), - sw_version=vehicle["last_state"]["vehicle_state"]["car_version"].split( - " " - )[0], - hw_version=vehicle["last_state"]["vehicle_config"]["driver_assist"], - serial_number=vehicle["vin"], - ), + device=DeviceInfo( + identifiers={(DOMAIN, vin)}, + manufacturer="Tesla", + configuration_url="https://my.tessie.com/", + name=vehicle["last_state"]["display_name"], + model=MODELS.get( + vehicle["last_state"]["vehicle_config"]["car_type"], + vehicle["last_state"]["vehicle_config"]["car_type"], + ), + sw_version=vehicle["last_state"]["vehicle_state"][ + "car_version" + ].split(" ")[0], + hw_version=vehicle["last_state"]["vehicle_config"]["driver_assist"], + serial_number=vin, + ), + ) ) - for vehicle in state_of_all_vehicles["results"] - if vehicle["last_state"] is not None - ] # Energy Sites - tessie = Tessie(session, api_key) energysites: list[TessieEnergyData] = [] try: diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 2a0c0e07f94..cbb5d1d27bf 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -10,8 +10,7 @@ from typing import TYPE_CHECKING, Any from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import TeslaEnergyPeriod from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError -from tesla_fleet_api.tessie import EnergySite -from tessie_api import get_state +from tesla_fleet_api.tessie import EnergySite, Vehicle from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -54,6 +53,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TessieConfigEntry, + api: Vehicle, api_key: str, vin: str, data: dict[str, Any], @@ -66,6 +66,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): name="Tessie", update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), ) + self.api = api self.api_key = api_key self.vin = vin self.session = async_get_clientsession(hass) @@ -74,12 +75,14 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Tessie API.""" try: - vehicle = await get_state( - session=self.session, - api_key=self.api_key, - vin=self.vin, - use_cache=True, - ) + vehicle = await self.api.state(use_cache=True) + except (InvalidToken, MissingToken) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: raise ConfigEntryAuthFailed from e diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index 306cb1597ae..1c56312237b 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations from copy import deepcopy -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -25,9 +25,10 @@ from .common import ( def mock_get_state(): """Mock get_state function.""" with patch( - "homeassistant.components.tessie.coordinator.get_state", - return_value=TEST_VEHICLE_STATE_ONLINE, + "tesla_fleet_api.tessie.Vehicle.state", + new_callable=AsyncMock, ) as mock_get_state: + mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE yield mock_get_state @@ -35,9 +36,10 @@ def mock_get_state(): def mock_get_state_of_all_vehicles(): """Mock get_state_of_all_vehicles function.""" with patch( - "homeassistant.components.tessie.get_state_of_all_vehicles", - return_value=TEST_STATE_OF_ALL_VEHICLES, + "tesla_fleet_api.tessie.Tessie.list_vehicles", + new_callable=AsyncMock, ) as mock_get_state_of_all_vehicles: + mock_get_state_of_all_vehicles.return_value = TEST_STATE_OF_ALL_VEHICLES yield mock_get_state_of_all_vehicles diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index e0ffd8fd57e..0e5b67375a3 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -1,13 +1,18 @@ """Test the Tessie init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.exceptions import ( + InvalidRequest, + InvalidToken, + ServiceUnavailable, + TeslaFleetError, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform +from .common import setup_platform async def test_load_unload(hass: HomeAssistant) -> None: @@ -20,32 +25,40 @@ async def test_load_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED +async def test_runtime_vehicle_api_handle_is_optional(hass: HomeAssistant) -> None: + """Test the runtime vehicle API handle remains optional during migration.""" + + entry = await setup_platform(hass) + assert all(vehicle.api is None for vehicle in entry.runtime_data.vehicles) + + async def test_auth_failure( - hass: HomeAssistant, mock_get_state_of_all_vehicles + hass: HomeAssistant, mock_get_state_of_all_vehicles: AsyncMock ) -> None: """Test init with an authentication error.""" - mock_get_state_of_all_vehicles.side_effect = ERROR_AUTH + mock_get_state_of_all_vehicles.side_effect = InvalidToken() entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_ERROR async def test_unknown_failure( - hass: HomeAssistant, mock_get_state_of_all_vehicles + hass: HomeAssistant, mock_get_state_of_all_vehicles: AsyncMock ) -> None: - """Test init with an client response error.""" + """Test init with a non-retryable fleet API error.""" - mock_get_state_of_all_vehicles.side_effect = ERROR_UNKNOWN + mock_get_state_of_all_vehicles.side_effect = InvalidRequest() entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.reason == "Failed to connect" -async def test_connection_failure( - hass: HomeAssistant, mock_get_state_of_all_vehicles +async def test_retryable_api_failure( + hass: HomeAssistant, mock_get_state_of_all_vehicles: AsyncMock ) -> None: - """Test init with a network connection error.""" + """Test init with a retryable fleet API error.""" - mock_get_state_of_all_vehicles.side_effect = ERROR_CONNECTION + mock_get_state_of_all_vehicles.side_effect = ServiceUnavailable() entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_RETRY