From 1b7db2ab086d935ad7b455a1dc5a64fab99f3fa0 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 5 Apr 2025 15:28:32 +0000 Subject: [PATCH 01/12] Add volvo integration --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/volvo/__init__.py | 61 +++ homeassistant/components/volvo/api.py | 40 ++ .../volvo/application_credentials.py | 57 +++ homeassistant/components/volvo/config_flow.py | 255 +++++++++++ homeassistant/components/volvo/const.py | 51 +++ homeassistant/components/volvo/coordinator.py | 220 ++++++++++ homeassistant/components/volvo/entity.py | 68 +++ homeassistant/components/volvo/icons.json | 78 ++++ homeassistant/components/volvo/manifest.json | 12 + .../components/volvo/quality_scale.yaml | 81 ++++ homeassistant/components/volvo/sensor.py | 405 ++++++++++++++++++ homeassistant/components/volvo/strings.json | 308 +++++++++++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + tests/components/volvo/__init__.py | 1 + tests/components/volvo/conftest.py | 96 +++++ tests/components/volvo/const.py | 6 + tests/components/volvo/test_config_flow.py | 297 +++++++++++++ 22 files changed, 2057 insertions(+) create mode 100644 homeassistant/components/volvo/__init__.py create mode 100644 homeassistant/components/volvo/api.py create mode 100644 homeassistant/components/volvo/application_credentials.py create mode 100644 homeassistant/components/volvo/config_flow.py create mode 100644 homeassistant/components/volvo/const.py create mode 100644 homeassistant/components/volvo/coordinator.py create mode 100644 homeassistant/components/volvo/entity.py create mode 100644 homeassistant/components/volvo/icons.json create mode 100644 homeassistant/components/volvo/manifest.json create mode 100644 homeassistant/components/volvo/quality_scale.yaml create mode 100644 homeassistant/components/volvo/sensor.py create mode 100644 homeassistant/components/volvo/strings.json create mode 100644 tests/components/volvo/__init__.py create mode 100644 tests/components/volvo/conftest.py create mode 100644 tests/components/volvo/const.py create mode 100644 tests/components/volvo/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index 0e00c2e9e07fc4..0681bc73d15c46 100644 --- a/.strict-typing +++ b/.strict-typing @@ -532,6 +532,7 @@ homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.vodafone_station.* +homeassistant.components.volvo.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* diff --git a/CODEOWNERS b/CODEOWNERS index 9e33407c7b899a..01b3729b05d771 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1674,6 +1674,8 @@ build.json @home-assistant/supervisor /tests/components/voip/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund +/homeassistant/components/volvo/ @thomasddn +/tests/components/volvo/ @thomasddn /homeassistant/components/volvooncall/ @molobrakos /tests/components/volvooncall/ @molobrakos /homeassistant/components/vulcan/ @Antoni-Czaplicki diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py new file mode 100644 index 00000000000000..587692f214eed0 --- /dev/null +++ b/homeassistant/components/volvo/__init__.py @@ -0,0 +1,61 @@ +"""The Volvo integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from .api import VolvoAuth +from .const import CONF_VIN, PLATFORMS +from .coordinator import VolvoConfigEntry, VolvoData, VolvoDataCoordinator +from .volvo_connected.api import VolvoCarsApi + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: + """Set up Volvo from a config entry.""" + _LOGGER.debug("%s - Loading entry", entry.entry_id) + + # Create APIs + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + web_session = aiohttp_client.async_get_clientsession(hass) + + auth = VolvoAuth(web_session, oauth_session) + api = VolvoCarsApi( + web_session, + auth, + entry.data.get(CONF_VIN, ""), + entry.data.get(CONF_API_KEY, ""), + ) + + # Setup entry + coordinator = VolvoDataCoordinator(hass, entry, api) + entry.runtime_data = VolvoData(coordinator) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Register events + entry.async_on_unload(entry.add_update_listener(_entry_update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("%s - Unloading entry", entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _entry_update_listener(hass: HomeAssistant, entry: VolvoConfigEntry) -> None: + """Reload entry after config changes.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py new file mode 100644 index 00000000000000..4ff272eff003c3 --- /dev/null +++ b/homeassistant/components/volvo/api.py @@ -0,0 +1,40 @@ +"""API for Volvo bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + +from .volvo_connected.auth import AccessTokenManager + + +# For more info see the docs at https://developers.home-assistant.io/docs/api_lib_auth/#oauth2. +class VolvoAuth(AccessTokenManager): + """Provide Volvo authentication tied to an OAuth2 based config entry.""" + + def __init__(self, websession: ClientSession, oauth_session: OAuth2Session) -> None: + """Initialize Volvo auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token["access_token"]) + + +class ConfigFlowVolvoAuth(AccessTokenManager): + """Provide Volvo authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__(self, websession: ClientSession, token: str) -> None: + """Initialize ConfigFlowVolvoAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Volvo API.""" + return self._token diff --git a/homeassistant/components/volvo/application_credentials.py b/homeassistant/components/volvo/application_credentials.py new file mode 100644 index 00000000000000..339cf1ad14dd73 --- /dev/null +++ b/homeassistant/components/volvo/application_credentials.py @@ -0,0 +1,57 @@ +"""Application credentials platform for the Volvo integration.""" + +from homeassistant.components.application_credentials import ( + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2Implementation, + LocalOAuth2ImplementationWithPkce, +) + +from .const import SCOPES +from .volvo_connected.auth import AUTHORIZE_URL, TOKEN_URL + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> AbstractOAuth2Implementation: + """Return auth implementation for a custom auth implementation.""" + return VolvoOAuth2Implementation( + hass, + auth_domain, + credential, + authorization_server=AuthorizationServer( + authorize_url=AUTHORIZE_URL, + token_url=TOKEN_URL, + ), + ) + + +class VolvoOAuth2Implementation(LocalOAuth2ImplementationWithPkce): + """Volvo oauth2 implementation.""" + + def __init__( + self, + hass: HomeAssistant, + auth_domain: str, + credential: ClientCredential, + authorization_server: AuthorizationServer, + ) -> None: + """Initialize.""" + super().__init__( + hass, + auth_domain, + credential.client_id, + authorization_server.authorize_url, + authorization_server.token_url, + credential.client_secret, + ) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return super().extra_authorize_data | { + "scope": " ".join(SCOPES), + } diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py new file mode 100644 index 00000000000000..b5f8b777c1f6ac --- /dev/null +++ b/homeassistant/components/volvo/config_flow.py @@ -0,0 +1,255 @@ +"""Config flow for Volvo.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from .api import ConfigFlowVolvoAuth +from .const import ( + CONF_VIN, + DOMAIN, + MANUFACTURER, + OPT_FUEL_CONSUMPTION_UNIT, + OPT_FUEL_UNIT_LITER_PER_100KM, + OPT_FUEL_UNIT_MPG_UK, + OPT_FUEL_UNIT_MPG_US, +) +from .coordinator import VolvoConfigEntry, VolvoData +from .volvo_connected.api import VolvoCarsApi +from .volvo_connected.models import VolvoApiException + +_LOGGER = logging.getLogger(__name__) + + +def _default_fuel_unit(hass: HomeAssistant) -> str: + if hass.config.country == "UK": + return OPT_FUEL_UNIT_MPG_UK + + if hass.config.units == US_CUSTOMARY_SYSTEM or hass.config.country == "US": + return OPT_FUEL_UNIT_MPG_US + + return OPT_FUEL_UNIT_LITER_PER_100KM + + +class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Volvo OAuth2 authentication.""" + + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Initialize Volvo config flow.""" + super().__init__() + + self._vins: list[str] = [] + self._config_data: dict = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + # Overridden method + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow.""" + self._config_data |= data + return await self.async_step_api_key() + + # By convention method + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + # By convention method + async def async_step_reconfigure( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + return await self.async_step_api_key() + + # Overridden method + @staticmethod + @callback + def async_get_options_flow(_: VolvoConfigEntry) -> VolvoOptionsFlowHandler: + """Create the options flow.""" + return VolvoOptionsFlowHandler() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self._get_reauth_entry().title}, + ) + return await self.async_step_user() + + async def async_step_api_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the API key step.""" + errors: dict[str, str] = {} + + if user_input is not None: + web_session = aiohttp_client.async_get_clientsession(self.hass) + token = self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN] + auth = ConfigFlowVolvoAuth(web_session, token) + api = VolvoCarsApi(web_session, auth, "", user_input[CONF_API_KEY]) + + try: + self._vins = await api.async_get_vehicles() + except VolvoApiException: + _LOGGER.exception("Unable to retrieve vehicles") + errors["base"] = "cannot_load_vehicles" + + if not errors: + self._config_data |= user_input + + if len(self._vins) == 1: + # If there is only one VIN, take that as value and + # immediately create the entry. No need to show + # additional step. + self._config_data[CONF_VIN] = self._vins[0] + return await self._async_create_or_update() + + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + # Don't let users change the VIN. The entry should be + # recreated if they want to change the VIN. + return await self._async_create_or_update() + + return await self.async_step_vin() + + if user_input is None: + if self.source == SOURCE_REAUTH: + user_input = self._config_data = dict(self._get_reauth_entry().data) + elif self.source == SOURCE_RECONFIGURE: + user_input = self._config_data = dict( + self._get_reconfigure_entry().data + ) + else: + user_input = {} + + schema = vol.Schema( + { + vol.Required( + CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") + ): str, + }, + ) + + return self.async_show_form( + step_id="api_key", data_schema=schema, errors=errors + ) + + async def async_step_vin( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the VIN step.""" + if user_input is not None: + self._config_data |= user_input + return await self._async_create_or_update() + + schema = vol.Schema( + { + vol.Required(CONF_VIN): SelectSelector( + SelectSelectorConfig( + options=self._vins, + multiple=False, + ) + ), + }, + ) + + return self.async_show_form(step_id="vin", data_schema=schema) + + async def _async_create_or_update(self) -> ConfigFlowResult: + vin = self._config_data[CONF_VIN] + await self.async_set_unique_id(vin) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=self._config_data, + ) + + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self._config_data, + reload_even_if_entry_is_unchanged=False, + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {vin}", + data=self._config_data, + options={OPT_FUEL_CONSUMPTION_UNIT: _default_fuel_unit(self.hass)}, + ) + + +class VolvoOptionsFlowHandler(OptionsFlow): + """Class to handle the options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + if TYPE_CHECKING: + assert isinstance(self.config_entry.runtime_data, VolvoData) + + coordinator = self.config_entry.runtime_data.coordinator + schema: dict[vol.Marker, Any] = {} + + if coordinator.vehicle.has_combustion_engine(): + schema.update( + { + vol.Required( + OPT_FUEL_CONSUMPTION_UNIT, + default=self.config_entry.options.get( + OPT_FUEL_CONSUMPTION_UNIT, OPT_FUEL_UNIT_LITER_PER_100KM + ), + ): SelectSelector( + SelectSelectorConfig( + options=[ + OPT_FUEL_UNIT_LITER_PER_100KM, + OPT_FUEL_UNIT_MPG_UK, + OPT_FUEL_UNIT_MPG_US, + ], + multiple=False, + translation_key=OPT_FUEL_CONSUMPTION_UNIT, + ) + ) + } + ) + + if len(schema) == 0: + return self.async_abort(reason="no_options_available") + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema), self.config_entry.options + ), + ) diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py new file mode 100644 index 00000000000000..43aea367dca4b4 --- /dev/null +++ b/homeassistant/components/volvo/const.py @@ -0,0 +1,51 @@ +"""Constants for the Volvo integration.""" + +from homeassistant.const import Platform + +DOMAIN = "volvo" +PLATFORMS: list[Platform] = [Platform.SENSOR] + +CONF_VIN = "vin" + +DATA_BATTERY_CAPACITY = "battery_capacity_kwh" + +MANUFACTURER = "Volvo" + +OPT_FUEL_CONSUMPTION_UNIT = "fuel_consumption_unit" +OPT_FUEL_UNIT_LITER_PER_100KM = "l_100km" +OPT_FUEL_UNIT_MPG_UK = "mpg_uk" +OPT_FUEL_UNIT_MPG_US = "mpg_us" + +SCOPES = [ + "openid", + "conve:battery_charge_level", + "conve:brake_status", + "conve:climatization_start_stop", + "conve:command_accessibility", + "conve:commands", + "conve:diagnostics_engine_status", + "conve:diagnostics_workshop", + "conve:doors_status", + "conve:engine_start_stop", + "conve:engine_status", + "conve:honk_flash", + "conve:fuel_status", + "conve:lock", + "conve:lock_status", + "conve:odometer_status", + "conve:trip_statistics", + "conve:tyre_status", + "conve:unlock", + "conve:vehicle_relation", + "conve:warnings", + "conve:windows_status", + "energy:battery_charge_level", + "energy:charging_connection_status", + "energy:charging_current_limit", + "energy:charging_system_status", + "energy:electric_range", + "energy:estimated_charging_time", + "energy:recharge_status", + "energy:target_battery_level", + "location:read", +] diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py new file mode 100644 index 00000000000000..2ef2f442f92d07 --- /dev/null +++ b/homeassistant/components/volvo/coordinator.py @@ -0,0 +1,220 @@ +"""Volvo coordinators.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any, cast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_VIN, DATA_BATTERY_CAPACITY, DOMAIN, MANUFACTURER +from .volvo_connected.api import VolvoCarsApi +from .volvo_connected.models import ( + VolvoApiException, + VolvoAuthException, + VolvoCarsApiBaseModel, + VolvoCarsValueField, + VolvoCarsVehicle, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class VolvoData: + """Data for Volvo Cars integration.""" + + coordinator: VolvoDataCoordinator + + def vin(self) -> str | None: + """Vin.""" + return self.coordinator.config_entry.data.get(CONF_VIN) + + +type VolvoConfigEntry = ConfigEntry[VolvoData] +type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None] + + +class VolvoDataCoordinator(DataUpdateCoordinator[CoordinatorData]): + """Volvo Data Coordinator.""" + + config_entry: VolvoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=135), + ) + + self.api = api + + self.vehicle: VolvoCarsVehicle + self.device: DeviceInfo + + # The variable is set during _async_setup(). + self._refresh_conditions: dict[ + str, tuple[Callable[[], Coroutine[Any, Any, Any]], bool] + ] = {} + + async def _async_setup(self) -> None: + """Set up the coordinator. + + This method is called automatically during + coordinator.async_config_entry_first_refresh. + """ + _LOGGER.debug("%s - Setting up", self.config_entry.entry_id) + + try: + vehicle = await self.api.async_get_vehicle_details() + + if vehicle is None: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="no_vehicle" + ) + + except VolvoAuthException as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": ex.message}, + ) from ex + + self.vehicle = vehicle + self.data = self.data or {} + + device_name = ( + f"{MANUFACTURER} {vehicle.description.model} {vehicle.model_year}" + if vehicle.fuel_type == "NONE" + else f"{MANUFACTURER} {vehicle.description.model} {vehicle.fuel_type} {vehicle.model_year}" + ) + + self.device = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer=MANUFACTURER, + model=f"{vehicle.description.model} ({vehicle.model_year})", + name=device_name, + serial_number=vehicle.vin, + ) + + self.hass.config_entries.async_update_entry( + self.config_entry, + title=f"{MANUFACTURER} {vehicle.description.model} ({vehicle.vin})", + ) + + self._refresh_conditions = { + "command_accessibility": (self.api.async_get_command_accessibility, True), + "diagnostics": (self.api.async_get_diagnostics, True), + "fuel": ( + self.api.async_get_fuel_status, + self.vehicle.has_combustion_engine(), + ), + "odometer": (self.api.async_get_odometer, True), + "recharge_status": ( + self.api.async_get_recharge_status, + self.vehicle.has_battery_engine(), + ), + "statistics": (self.api.async_get_statistics, True), + } + + async def _async_update_data(self) -> CoordinatorData: + """Fetch data from API.""" + _LOGGER.debug("%s - Updating data", self.config_entry.entry_id) + + api_calls = self._get_api_calls() + data: CoordinatorData = {} + valid = 0 + exception: Exception | None = None + + results = await asyncio.gather( + *(call() for call in api_calls), return_exceptions=True + ) + + for result in results: + if isinstance(result, VolvoAuthException): + # If one result is a VolvoAuthException, then probably all requests + # will fail. In this case we can cancel everything to + # reauthenticate. + # + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + _LOGGER.exception( + "%s - Authentication failed. %s", + self.config_entry.entry_id, + result.message, + ) + raise ConfigEntryAuthFailed( + f"Authentication failed. {result.message}" + ) from result + + if isinstance(result, VolvoApiException): + # Maybe it's just one call that fails. Log the error and + # continue processing the other calls. + _LOGGER.debug( + "%s - Error during data update: %s", + self.config_entry.entry_id, + result.message, + ) + exception = exception or result + continue + + if isinstance(result, Exception): + # Something bad happened, raise immediately. + raise result + + data |= cast(CoordinatorData, result) + valid += 1 + + # Raise an error if not a single API call succeeded + if valid == 0: + if exception: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from exception + + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) + + # Add static values + data[DATA_BATTERY_CAPACITY] = VolvoCarsValueField.from_dict( + { + "value": self.vehicle.battery_capacity_kwh, + "timestamp": self.config_entry.modified_at, + } + ) + + return data + + def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None: + """Get the API field based on the entity description.""" + + return self.data.get(api_field) if api_field else None + + def _get_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + return [ + api_call + for _, (api_call, condition) in self._refresh_conditions.items() + if condition + ] diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py new file mode 100644 index 00000000000000..ae187fad8287a7 --- /dev/null +++ b/homeassistant/components/volvo/entity.py @@ -0,0 +1,68 @@ +"""Volvo entity classes.""" + +from dataclasses import dataclass + +from homeassistant.core import callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_VIN, DOMAIN +from .coordinator import VolvoDataCoordinator +from .volvo_connected.models import VolvoCarsApiBaseModel + + +def get_unique_id(vin: str, key: str) -> str: + """Get the unique ID.""" + return f"{DOMAIN}_{vin}_{key}".lower() + + +def value_to_translation_key(value: str) -> str: + """Make sure the translation key is valid.""" + return value.lower() + + +@dataclass(frozen=True, kw_only=True) +class VolvoEntityDescription(EntityDescription): + """Describes a Volvo entity.""" + + api_field: str + + +class VolvoEntity(CoordinatorEntity[VolvoDataCoordinator]): + """Volvo base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: VolvoDataCoordinator, + description: VolvoEntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator) + + self.entity_description: VolvoEntityDescription = description + + self._attr_unique_id = get_unique_id( + coordinator.config_entry.data[CONF_VIN], description.key + ) + + self._attr_device_info = coordinator.device + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + api_field = self.coordinator.get_api_field(self.entity_description.api_field) + + self._attr_available = super().available and api_field is not None + self._update_state(api_field) + + super()._handle_coordinator_update() + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + pass diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json new file mode 100644 index 00000000000000..fe9b1a379ebe83 --- /dev/null +++ b/homeassistant/components/volvo/icons.json @@ -0,0 +1,78 @@ +{ + "entity": { + "sensor": { + "availability": { + "default": "mdi:car-connected" + }, + "average_energy_consumption": { + "default": "mdi:car-electric" + }, + "average_energy_consumption_automatic": { + "default": "mdi:car-electric" + }, + "average_energy_consumption_charge": { + "default": "mdi:car-electric" + }, + "average_fuel_consumption": { + "default": "mdi:gas-station" + }, + "average_fuel_consumption_automatic": { + "default": "mdi:gas-station" + }, + "average_speed": { + "default": "mdi:speedometer" + }, + "average_speed_automatic": { + "default": "mdi:speedometer" + }, + "battery_capacity": { + "default": "mdi:car-battery" + }, + "battery_charge_level": { + "default": "mdi:battery" + }, + "charging_connection_status": { + "default": "mdi:ev-plug-ccs2" + }, + "charging_current_limit": { + "default": "mdi:current-ac" + }, + "charging_system_status": { + "default": "mdi:ev-station" + }, + "distance_to_empty_battery": { + "default": "mdi:gauge-empty" + }, + "distance_to_empty_tank": { + "default": "mdi:gauge-empty" + }, + "distance_to_service": { + "default": "mdi:wrench-clock" + }, + "engine_time_to_service": { + "default": "mdi:wrench-clock" + }, + "estimated_charging_time": { + "default": "mdi:battery-clock" + }, + "fuel_amount": { + "default": "mdi:gas-station" + }, + "odometer": { + "default": "mdi:counter" + }, + "target_battery_charge_level": { + "default": "mdi:battery-medium" + }, + "time_to_service": { + "default": "mdi:wrench-clock" + }, + "trip_meter_automatic": { + "default": "mdi:map-marker-distance" + }, + "trip_meter_manual": { + "default": "mdi:map-marker-distance" + } + } + } +} diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json new file mode 100644 index 00000000000000..3ab6b2d0cf356a --- /dev/null +++ b/homeassistant/components/volvo/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "volvo", + "name": "Volvo", + "codeowners": ["@thomasddn"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/volvo", + "integration_type": "device", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": [] +} diff --git a/homeassistant/components/volvo/quality_scale.yaml b/homeassistant/components/volvo/quality_scale.yaml new file mode 100644 index 00000000000000..0cbed9862fe24d --- /dev/null +++ b/homeassistant/components/volvo/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No discovery possible. + discovery: + status: exempt + comment: | + No discovery possible. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + No dynamic devices possible. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: todo + stale-devices: + status: exempt + comment: | + No dynamic devices possible. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py new file mode 100644 index 00000000000000..0bc296138a8a8f --- /dev/null +++ b/homeassistant/components/volvo/sensor.py @@ -0,0 +1,405 @@ +"""Volvo sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfEnergyDistance, + UnitOfLength, + UnitOfSpeed, + UnitOfTime, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + DATA_BATTERY_CAPACITY, + OPT_FUEL_CONSUMPTION_UNIT, + OPT_FUEL_UNIT_LITER_PER_100KM, + OPT_FUEL_UNIT_MPG_UK, + OPT_FUEL_UNIT_MPG_US, +) +from .coordinator import VolvoConfigEntry, VolvoDataCoordinator +from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key +from .volvo_connected.models import ( + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsValueField, + VolvoCarsVehicle, +) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): + """Describes a Volvo sensor entity.""" + + value_fn: Callable[[VolvoCarsValue, VolvoConfigEntry], Any] | None = None + unit_fn: Callable[[VolvoConfigEntry], str] | None = None + available_fn: Callable[[VolvoCarsVehicle], bool] = lambda vehicle: True + + +def _availability_status(field: VolvoCarsValue, _: VolvoConfigEntry) -> str: + reason = field.get("unavailable_reason") + return reason if reason else str(field.value) + + +def _calculate_time_to_service(field: VolvoCarsValue, _: VolvoConfigEntry) -> int: + value = int(field.value) + + # Always express value in days + if isinstance(field, VolvoCarsValueField) and field.unit == "months": + return value * 30 + + return value + + +def _determine_fuel_consumption_unit(entry: VolvoConfigEntry) -> str: + unit_key = entry.options.get( + OPT_FUEL_CONSUMPTION_UNIT, OPT_FUEL_UNIT_LITER_PER_100KM + ) + + return ( + "mpg" + if unit_key in (OPT_FUEL_UNIT_MPG_UK, OPT_FUEL_UNIT_MPG_US) + else "L/100 km" + ) + + +def _convert_fuel_consumption( + field: VolvoCarsValue, entry: VolvoConfigEntry +) -> Decimal: + value = Decimal(field.value) + + decimals = 1 + converted_value = value + + unit_key = entry.options.get( + OPT_FUEL_CONSUMPTION_UNIT, OPT_FUEL_UNIT_LITER_PER_100KM + ) + + if unit_key == OPT_FUEL_UNIT_MPG_UK: + decimals = 2 + converted_value = (Decimal("282.481") / value) if value else Decimal(0) + + elif unit_key == OPT_FUEL_UNIT_MPG_US: + decimals = 2 + converted_value = (Decimal("235.215") / value) if value else Decimal(0) + + return round(converted_value, decimals) + + +_DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( + # command-accessibility endpoint + VolvoSensorDescription( + key="availability", + translation_key="availability", + api_field="availabilityStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "available", + "car_in_use", + "no_internet", + "power_saving_mode", + "unspecified", + ], + value_fn=_availability_status, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption", + translation_key="average_energy_consumption", + api_field="averageEnergyConsumption", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption_automatic", + translation_key="average_energy_consumption_automatic", + api_field="averageEnergyConsumptionAutomatic", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption_charge", + translation_key="average_energy_consumption_charge", + api_field="averageEnergyConsumptionSinceCharge", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # statistics endpoint + VolvoSensorDescription( + key="average_fuel_consumption", + translation_key="average_fuel_consumption", + api_field="averageFuelConsumption", + native_unit_of_measurement="L/100 km", + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_combustion_engine(), + unit_fn=_determine_fuel_consumption_unit, + value_fn=_convert_fuel_consumption, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_fuel_consumption_automatic", + translation_key="average_fuel_consumption_automatic", + api_field="averageFuelConsumptionAutomatic", + native_unit_of_measurement="L/100 km", + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_combustion_engine(), + unit_fn=_determine_fuel_consumption_unit, + value_fn=_convert_fuel_consumption, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_speed", + translation_key="average_speed", + api_field="averageSpeed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_speed_automatic", + translation_key="average_speed_automatic", + api_field="averageSpeedAutomatic", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + # vehicle endpoint + VolvoSensorDescription( + key="battery_capacity", + translation_key="battery_capacity", + api_field=DATA_BATTERY_CAPACITY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + entity_category=EntityCategory.DIAGNOSTIC, + ), + # fuel & recharge-status endpoint + VolvoSensorDescription( + key="battery_charge_level", + translation_key="battery_charge_level", + api_field="batteryChargeLevel", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # recharge-status endpoint + VolvoSensorDescription( + key="charging_connection_status", + translation_key="charging_connection_status", + api_field="chargingConnectionStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "connection_status_connected_ac", + "connection_status_connected_dc", + "connection_status_disconnected", + "connection_status_fault", + "connection_status_unspecified", + ], + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # recharge-status endpoint + VolvoSensorDescription( + key="charging_current_limit", + translation_key="charging_current_limit", + api_field="chargingCurrentLimit", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # recharge-status endpoint + VolvoSensorDescription( + key="charging_system_status", + translation_key="charging_system_status", + api_field="chargingSystemStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "charging_system_charging", + "charging_system_done", + "charging_system_fault", + "charging_system_idle", + "charging_system_scheduled", + "charging_system_unspecified", + ], + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # statistics endpoint + VolvoSensorDescription( + key="distance_to_empty_battery", + translation_key="distance_to_empty_battery", + api_field="distanceToEmptyBattery", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # statistics endpoint + VolvoSensorDescription( + key="distance_to_empty_tank", + translation_key="distance_to_empty_tank", + api_field="distanceToEmptyTank", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_combustion_engine(), + ), + # diagnostics endpoint + VolvoSensorDescription( + key="distance_to_service", + translation_key="distance_to_service", + api_field="distanceToService", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="engine_time_to_service", + translation_key="engine_time_to_service", + api_field="engineHoursToService", + native_unit_of_measurement=UnitOfTime.HOURS, + ), + # recharge-status endpoint + VolvoSensorDescription( + key="estimated_charging_time", + translation_key="estimated_charging_time", + api_field="estimatedChargingTime", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # fuel endpoint + VolvoSensorDescription( + key="fuel_amount", + translation_key="fuel_amount", + api_field="fuelAmount", + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + available_fn=lambda vehicle: vehicle.has_combustion_engine(), + ), + # odometer endpoint + VolvoSensorDescription( + key="odometer", + translation_key="odometer", + api_field="odometer", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL, + ), + # recharge-status endpoint + VolvoSensorDescription( + key="target_battery_charge_level", + translation_key="target_battery_charge_level", + api_field="targetBatteryChargeLevel", + native_unit_of_measurement=PERCENTAGE, + available_fn=lambda vehicle: vehicle.has_battery_engine(), + ), + # diagnostics endpoint + VolvoSensorDescription( + key="time_to_service", + translation_key="time_to_service", + api_field="timeToService", + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=_calculate_time_to_service, + ), + # statistics endpoint + VolvoSensorDescription( + key="trip_meter_automatic", + translation_key="trip_meter_automatic", + api_field="tripMeterAutomatic", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + # statistics endpoint + VolvoSensorDescription( + key="trip_meter_manual", + translation_key="trip_meter_manual", + api_field="tripMeterManual", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + + +async def async_setup_entry( + _: HomeAssistant, + entry: VolvoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors.""" + + coordinator = entry.runtime_data.coordinator + items = [ + VolvoSensor(coordinator, description) + for description in _DESCRIPTIONS + if description.api_field in coordinator.data + and description.available_fn(coordinator.vehicle) + ] + + async_add_entities(items) + + +class VolvoSensor(VolvoEntity, SensorEntity): + """Volvo sensor.""" + + entity_description: VolvoSensorDescription + + def __init__( + self, + coordinator: VolvoDataCoordinator, + description: VolvoSensorDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, description) + + if description.unit_fn: + self._attr_native_unit_of_measurement = description.unit_fn( + self.coordinator.config_entry + ) + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + if not isinstance(api_field, VolvoCarsValue): + return + + native_value = ( + api_field.value + if self.entity_description.value_fn is None + else self.entity_description.value_fn( + api_field, self.coordinator.config_entry + ) + ) + + if self.device_class == SensorDeviceClass.ENUM: + native_value = value_to_translation_key(str(native_value)) + + self._attr_native_value = native_value diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json new file mode 100644 index 00000000000000..64ceb0881451b2 --- /dev/null +++ b/homeassistant/components/volvo/strings.json @@ -0,0 +1,308 @@ +{ + "common": { + "api_timestamp": "API timestamp", + "error": "Error", + "unknown": "Unknown" + }, + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Volvo integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "api_key": { + "data": { + "api_key": "API key" + }, + "data_description": { + "api_key": "API key" + }, + "description": "Enter the Volvo developers API key (https://developer.volvocars.com/)." + }, + "vin": { + "data": { + "vin": "Vehicle Identification Number" + }, + "data_description": { + "vin": "Vehicle Identification Number" + }, + "description": "Choose the VIN of the vehicle you want to add." + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "error": { + "cannot_load_vehicles": "Unable to retrieve vehicles.", + "invalid_vin": "VIN is invalid" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "options": { + "abort": { + "no_options_available": "No options available for your vehicle." + }, + "step": { + "init": { + "data": { + "fuel_consumption_unit": "Fuel consumption unit" + } + } + } + }, + "selector": { + "fuel_consumption_unit": { + "options": { + "l_100km": "L/100 km", + "mpg_uk": "mpg (UK)", + "mpg_us": "mpg (US)" + } + } + }, + "entity": { + "sensor": { + "availability": { + "name": "Car connection", + "state": { + "available": "Available", + "car_in_use": "Car is in use", + "no_internet": "No internet", + "power_saving_mode": "Power saving mode", + "unavailable": "Unavailable", + "unspecified": "[%key:component::volvo::common::unknown%]" + }, + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "average_energy_consumption": { + "name": "TM Avg. energy consumption", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "average_energy_consumption_automatic": { + "name": "TA Avg. energy consumption", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "average_energy_consumption_charge": { + "name": "Avg. energy consumption since charge", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "average_fuel_consumption": { + "name": "TM Avg. fuel consumption", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "average_fuel_consumption_automatic": { + "name": "TA Avg. fuel consumption", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "average_speed": { + "name": "TM Avg. speed", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "average_speed_automatic": { + "name": "TA Avg. speed", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "battery_capacity": { + "name": "Battery capacity", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "battery_charge_level": { + "name": "Battery charge level", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "charging_current_limit": { + "name": "Charging limit", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "charging_connection_status": { + "name": "Charging connection status", + "state": { + "connection_status_connected_ac": "Connected AC", + "connection_status_connected_dc": "Connected DC", + "connection_status_disconnected": "Disconnected", + "connection_status_fault": "[%key:component::volvo::common::error%]", + "connection_status_unspecified": "[%key:component::volvo::common::unknown%]" + }, + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "charging_system_status": { + "name": "Charging status", + "state": { + "charging_system_charging": "Charging", + "charging_system_done": "Done", + "charging_system_fault": "[%key:component::volvo::common::error%]", + "charging_system_idle": "Idle", + "charging_system_scheduled": "Scheduled", + "charging_system_unspecified": "[%key:component::volvo::common::unknown%]" + }, + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "distance_to_empty_battery": { + "name": "Distance to empty battery", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "distance_to_empty_tank": { + "name": "Distance to empty tank", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "distance_to_service": { + "name": "Distance to service", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "engine_time_to_service": { + "name": "Engine time to service", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "estimated_charging_time": { + "name": "Est. charging time", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "fuel_amount": { + "name": "Fuel amount", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "odometer": { + "name": "Odometer", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "target_battery_charge_level": { + "name": "Target battery charge level", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "time_to_service": { + "name": "Time to service", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "trip_meter_automatic": { + "name": "TA Distance", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "trip_meter_manual": { + "name": "TM Distance", + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + } + } + }, + "exceptions": { + "no_vehicle": { + "message": "Unable to retrieve vehicle details." + }, + "update_failed": { + "message": "Unable to update data." + }, + "unauthorized": { + "message": "Authentication failed. {message}" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 68c6de405e6d19..9991295cceaae8 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -33,6 +33,7 @@ "spotify", "tesla_fleet", "twitch", + "volvo", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5a292995f01b55..9fabcfc8932de0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -684,6 +684,7 @@ "vodafone_station", "voip", "volumio", + "volvo", "volvooncall", "vulcan", "wake_on_lan", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 52fb10e1886310..bd5205b0fa5074 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7090,6 +7090,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "volvo": { + "name": "Volvo", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "volvooncall": { "name": "Volvo On Call", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 852678677bb321..f81309397a8039 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5079,6 +5079,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.volvo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.wake_on_lan.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/volvo/__init__.py b/tests/components/volvo/__init__.py new file mode 100644 index 00000000000000..39ef6c9703e443 --- /dev/null +++ b/tests/components/volvo/__init__.py @@ -0,0 +1 @@ +"""Tests for the Volvo integration.""" diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py new file mode 100644 index 00000000000000..b4ff6d2f9bac94 --- /dev/null +++ b/tests/components/volvo/conftest.py @@ -0,0 +1,96 @@ +"""Define fixtures for volvo unit tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from yarl import URL + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.volvo.const import CONF_VIN, DOMAIN, SCOPES +from homeassistant.components.volvo.volvo_connected.auth import AUTHORIZE_URL +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from .const import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +async def config_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + setup_credentials: None, +) -> config_entries.ConfigFlowResult: + """Initialize a new config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + result_url = URL(result["url"]) + assert f"{result_url.origin()}{result_url.path}" == AUTHORIZE_URL + assert result_url.query["response_type"] == "code" + assert result_url.query["client_id"] == CLIENT_ID + assert result_url.query["redirect_uri"] == REDIRECT_URI + assert result_url.query["state"] == state + assert result_url.query["code_challenge"] + assert result_url.query["code_challenge_method"] == "S256" + assert result_url.query["scope"] == " ".join(SCOPES) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + return result + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="YV123456789", + data={ + CONF_API_KEY: "abcdef0123456879abcdef", + CONF_VIN: "YV123456789", + CONF_TOKEN: {CONF_ACCESS_TOKEN: "mock-access-token"}, + }, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.volvo.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/volvo/const.py b/tests/components/volvo/const.py new file mode 100644 index 00000000000000..c64122aea4f06a --- /dev/null +++ b/tests/components/volvo/const.py @@ -0,0 +1,6 @@ +"""Define const for volvo unit tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +REDIRECT_URI = "https://example.com/auth/external/callback" diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py new file mode 100644 index 00000000000000..198111652102d0 --- /dev/null +++ b/tests/components/volvo/test_config_flow.py @@ -0,0 +1,297 @@ +"""Test the Volvo config flow.""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from homeassistant.components.volvo.const import ( + CONF_VIN, + DOMAIN, + OPT_FUEL_CONSUMPTION_UNIT, + OPT_FUEL_UNIT_LITER_PER_100KM, + OPT_FUEL_UNIT_MPG_UK, + OPT_FUEL_UNIT_MPG_US, +) +from homeassistant.components.volvo.volvo_connected.api import ( + _API_CONNECTED_ENDPOINT, + _API_URL, +) +from homeassistant.components.volvo.volvo_connected.auth import TOKEN_URL +from homeassistant.components.volvo.volvo_connected.models import VolvoApiException +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem + +from .const import REDIRECT_URI + +from tests.common import METRIC_SYSTEM, MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("country", "units", "expected_fuel_unit"), + [ + ("BE", METRIC_SYSTEM, OPT_FUEL_UNIT_LITER_PER_100KM), + ("NL", METRIC_SYSTEM, OPT_FUEL_UNIT_LITER_PER_100KM), + ("BE", US_CUSTOMARY_SYSTEM, OPT_FUEL_UNIT_MPG_US), + ("UK", METRIC_SYSTEM, OPT_FUEL_UNIT_MPG_UK), + ("UK", US_CUSTOMARY_SYSTEM, OPT_FUEL_UNIT_MPG_UK), + ("US", US_CUSTOMARY_SYSTEM, OPT_FUEL_UNIT_MPG_US), + ("US", METRIC_SYSTEM, OPT_FUEL_UNIT_MPG_US), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + aioclient_mock: AiohttpClientMocker, + *, + country: str, + units: UnitSystem, + expected_fuel_unit: str, +) -> None: + """Check full flow.""" + hass.config.country = country + hass.config.units = units + + config_flow = await _async_run_flow_to_completion(hass, config_flow, aioclient_mock) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert config_flow["type"] is FlowResultType.CREATE_ENTRY + assert config_flow["options"][OPT_FUEL_CONSUMPTION_UNIT] == expected_fuel_unit + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_single_vin_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check flow where API returns a single VIN.""" + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + aioclient_mock.get( + f"{_API_URL}{_API_CONNECTED_ENDPOINT}", + json={ + "data": [{"vin": "YV123456789"}], + }, + ) + + # Since there is only one VIN, the api_key step is the only step + config_flow = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert config_flow["step_id"] == "api_key" + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host", "setup_credentials") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauthentication flow.""" + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await _async_run_flow_to_completion( + hass, result, aioclient_mock, has_vin_step=False + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reconfiguration flow.""" + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "api_key" + + aioclient_mock.get( + f"{_API_URL}{_API_CONNECTED_ENDPOINT}", + json={ + "data": [{"vin": "YV123456789"}, {"vin": "YV198765432"}], + }, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.usefixtures("current_request_with_host", "mock_config_entry") +async def test_unique_id_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test unique ID flow.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + config_flow = await _async_run_flow_to_completion(hass, config_flow, aioclient_mock) + + assert config_flow["type"] is FlowResultType.ABORT + assert config_flow["reason"] == "already_configured" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_api_failure_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check flow where API throws an exception.""" + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + aioclient_mock.get( + f"{_API_URL}{_API_CONNECTED_ENDPOINT}", + exc=VolvoApiException(), + ) + + config_flow = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert config_flow["step_id"] == "api_key" + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["errors"]["base"] == "cannot_load_vehicles" + assert config_flow["step_id"] == "api_key" + + +@pytest.mark.parametrize( + ("fuel_unit"), + [ + (OPT_FUEL_UNIT_LITER_PER_100KM), + (OPT_FUEL_UNIT_MPG_UK), + (OPT_FUEL_UNIT_MPG_US), + ], +) +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, fuel_unit: str +) -> None: + """Test options flow.""" + mock_config_entry.runtime_data = AsyncMock() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={OPT_FUEL_CONSUMPTION_UNIT: fuel_unit} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config_entry.options[OPT_FUEL_CONSUMPTION_UNIT] == fuel_unit + + +async def test_no_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow where no options are available.""" + mock_config_entry.runtime_data = AsyncMock() + mock_config_entry.runtime_data.coordinator = AsyncMock() + mock_config_entry.runtime_data.coordinator.vehicle = AsyncMock() + mock_config_entry.runtime_data.coordinator.vehicle.has_combustion_engine = Mock( + return_value=False + ) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_options_available" + + +async def _async_run_flow_to_completion( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + aioclient_mock: AiohttpClientMocker, + *, + has_vin_step: bool = True, +) -> ConfigFlowResult: + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 60, + }, + ) + + aioclient_mock.get( + f"{_API_URL}{_API_CONNECTED_ENDPOINT}", + json={ + "data": [{"vin": "YV123456789"}, {"vin": "YV198765432"}], + }, + ) + + config_flow = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["step_id"] == "api_key" + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + if has_vin_step: + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["step_id"] == "vin" + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_VIN: "YV123456789"} + ) + + return config_flow From 544cb496fe01088d26dfacebda64ec81b355282f Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 8 Apr 2025 15:29:36 +0000 Subject: [PATCH 02/12] Use pypi requirement --- homeassistant/components/volvo/__init__.py | 3 ++- homeassistant/components/volvo/api.py | 3 +-- .../components/volvo/application_credentials.py | 3 ++- homeassistant/components/volvo/config_flow.py | 4 ++-- homeassistant/components/volvo/coordinator.py | 17 +++++++++-------- homeassistant/components/volvo/entity.py | 3 ++- homeassistant/components/volvo/manifest.json | 3 ++- homeassistant/components/volvo/sensor.py | 13 +++++++------ homeassistant/components/volvo/strings.json | 13 ++++++++----- requirements_all.txt | 3 +++ requirements_test_all.txt | 3 +++ tests/components/volvo/conftest.py | 2 +- tests/components/volvo/test_config_flow.py | 9 +++------ 13 files changed, 45 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index 587692f214eed0..44da7833194a20 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -4,6 +4,8 @@ import logging +from volvocarsapi.api import VolvoCarsApi + from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow @@ -11,7 +13,6 @@ from .api import VolvoAuth from .const import CONF_VIN, PLATFORMS from .coordinator import VolvoConfigEntry, VolvoData, VolvoDataCoordinator -from .volvo_connected.api import VolvoCarsApi _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py index 4ff272eff003c3..f704547b5ad040 100644 --- a/homeassistant/components/volvo/api.py +++ b/homeassistant/components/volvo/api.py @@ -3,11 +3,10 @@ from typing import cast from aiohttp import ClientSession +from volvocarsapi.auth import AccessTokenManager from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session -from .volvo_connected.auth import AccessTokenManager - # For more info see the docs at https://developers.home-assistant.io/docs/api_lib_auth/#oauth2. class VolvoAuth(AccessTokenManager): diff --git a/homeassistant/components/volvo/application_credentials.py b/homeassistant/components/volvo/application_credentials.py index 339cf1ad14dd73..24b7e44566e676 100644 --- a/homeassistant/components/volvo/application_credentials.py +++ b/homeassistant/components/volvo/application_credentials.py @@ -1,5 +1,7 @@ """Application credentials platform for the Volvo integration.""" +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL + from homeassistant.components.application_credentials import ( AuthorizationServer, ClientCredential, @@ -11,7 +13,6 @@ ) from .const import SCOPES -from .volvo_connected.auth import AUTHORIZE_URL, TOKEN_URL async def async_get_auth_implementation( diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py index b5f8b777c1f6ac..0e38da1f161900 100644 --- a/homeassistant/components/volvo/config_flow.py +++ b/homeassistant/components/volvo/config_flow.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoApiException from homeassistant.config_entries import ( SOURCE_REAUTH, @@ -32,8 +34,6 @@ OPT_FUEL_UNIT_MPG_US, ) from .coordinator import VolvoConfigEntry, VolvoData -from .volvo_connected.api import VolvoCarsApi -from .volvo_connected.models import VolvoApiException _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index 2ef2f442f92d07..42a8c51af23609 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -9,6 +9,15 @@ import logging from typing import Any, cast +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoApiException, + VolvoAuthException, + VolvoCarsApiBaseModel, + VolvoCarsValueField, + VolvoCarsVehicle, +) + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError @@ -16,14 +25,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_VIN, DATA_BATTERY_CAPACITY, DOMAIN, MANUFACTURER -from .volvo_connected.api import VolvoCarsApi -from .volvo_connected.models import ( - VolvoApiException, - VolvoAuthException, - VolvoCarsApiBaseModel, - VolvoCarsValueField, - VolvoCarsVehicle, -) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py index ae187fad8287a7..5108e756db140b 100644 --- a/homeassistant/components/volvo/entity.py +++ b/homeassistant/components/volvo/entity.py @@ -2,13 +2,14 @@ from dataclasses import dataclass +from volvocarsapi.models import VolvoCarsApiBaseModel + from homeassistant.core import callback from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_VIN, DOMAIN from .coordinator import VolvoDataCoordinator -from .volvo_connected.models import VolvoCarsApiBaseModel def get_unique_id(vin: str, key: str) -> str: diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json index 3ab6b2d0cf356a..de1d9f7c70c67a 100644 --- a/homeassistant/components/volvo/manifest.json +++ b/homeassistant/components/volvo/manifest.json @@ -7,6 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/volvo", "integration_type": "device", "iot_class": "cloud_polling", + "loggers": ["volvocarsapi"], "quality_scale": "bronze", - "requirements": [] + "requirements": ["volvocarsapi==0.0.2b0"] } diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index 0bc296138a8a8f..047468a7bbc49b 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -7,6 +7,13 @@ from decimal import Decimal from typing import Any +from volvocarsapi.models import ( + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsValueField, + VolvoCarsVehicle, +) + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -36,12 +43,6 @@ ) from .coordinator import VolvoConfigEntry, VolvoDataCoordinator from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key -from .volvo_connected.models import ( - VolvoCarsApiBaseModel, - VolvoCarsValue, - VolvoCarsValueField, - VolvoCarsVehicle, -) PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 64ceb0881451b2..34e75c90894dec 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -18,18 +18,18 @@ "api_key": "API key" }, "data_description": { - "api_key": "API key" + "api_key": "The Volvo developers API key (https://developer.volvocars.com/)" }, - "description": "Enter the Volvo developers API key (https://developer.volvocars.com/)." + "description": "API" }, "vin": { "data": { - "vin": "Vehicle Identification Number" + "vin": "VIN" }, "data_description": { - "vin": "Vehicle Identification Number" + "vin": "The Vehicle Identification Number of the vehicle you want to add" }, - "description": "Choose the VIN of the vehicle you want to add." + "description": "Vehicle" } }, "abort": { @@ -62,6 +62,9 @@ "init": { "data": { "fuel_consumption_unit": "Fuel consumption unit" + }, + "data_description": { + "fuel_consumption_unit": "Choose the preferred unit" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 63f7a299746a3e..b432a27e7fa3b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3020,6 +3020,9 @@ voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 +# homeassistant.components.volvo +volvocarsapi==0.0.2b0 + # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1e2e8590e319f..84bc9d56ef1c25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2437,6 +2437,9 @@ vilfo-api-client==0.5.0 # homeassistant.components.voip voip-utils==0.3.1 +# homeassistant.components.volvo +volvocarsapi==0.0.2b0 + # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py index b4ff6d2f9bac94..c057ec7998d8b9 100644 --- a/tests/components/volvo/conftest.py +++ b/tests/components/volvo/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest +from volvocarsapi.auth import AUTHORIZE_URL from yarl import URL from homeassistant import config_entries @@ -12,7 +13,6 @@ async_import_client_credential, ) from homeassistant.components.volvo.const import CONF_VIN, DOMAIN, SCOPES -from homeassistant.components.volvo.volvo_connected.auth import AUTHORIZE_URL from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py index 198111652102d0..56d0ed54cfae8a 100644 --- a/tests/components/volvo/test_config_flow.py +++ b/tests/components/volvo/test_config_flow.py @@ -3,6 +3,9 @@ from unittest.mock import AsyncMock, Mock import pytest +from volvocarsapi.api import _API_CONNECTED_ENDPOINT, _API_URL +from volvocarsapi.auth import TOKEN_URL +from volvocarsapi.models import VolvoApiException from homeassistant.components.volvo.const import ( CONF_VIN, @@ -12,12 +15,6 @@ OPT_FUEL_UNIT_MPG_UK, OPT_FUEL_UNIT_MPG_US, ) -from homeassistant.components.volvo.volvo_connected.api import ( - _API_CONNECTED_ENDPOINT, - _API_URL, -) -from homeassistant.components.volvo.volvo_connected.auth import TOKEN_URL -from homeassistant.components.volvo.volvo_connected.models import VolvoApiException from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant From 4b3701bb03d7fbebc0f8cf6434483cbde1b20242 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Thu, 10 Apr 2025 14:05:30 +0000 Subject: [PATCH 03/12] Add unit tests for sensor --- homeassistant/components/volvo/const.py | 2 + homeassistant/components/volvo/entity.py | 18 ++- homeassistant/components/volvo/sensor.py | 3 +- tests/components/volvo/common.py | 30 ++++ tests/components/volvo/conftest.py | 144 +++++++++++++++--- tests/components/volvo/const.py | 2 +- .../volvo/fixtures/availability.json | 6 + tests/components/volvo/fixtures/brakes.json | 6 + tests/components/volvo/fixtures/commands.json | 36 +++++ .../volvo/fixtures/diagnostics.json | 25 +++ tests/components/volvo/fixtures/doors.json | 34 +++++ .../volvo/fixtures/engine_status.json | 6 + .../fixtures/engine_status_no_timestamp.json | 5 + .../volvo/fixtures/engine_warnings.json | 10 ++ .../volvo/fixtures/ex30_2024/vehicle.json | 17 +++ .../fixtures/ex30_2024_no_colour/vehicle.json | 16 ++ .../volvo/fixtures/fuel_status.json | 12 ++ tests/components/volvo/fixtures/location.json | 11 ++ .../volvo/fixtures/location_no_timestamp.json | 10 ++ tests/components/volvo/fixtures/odometer.json | 7 + .../volvo/fixtures/recharge_status.json | 25 +++ .../fixtures/s90_diesel_2018/diagnostics.json | 25 +++ .../fixtures/s90_diesel_2018/vehicle.json | 16 ++ .../components/volvo/fixtures/statistics.json | 37 +++++ tests/components/volvo/fixtures/tyres.json | 18 +++ tests/components/volvo/fixtures/warnings.json | 94 ++++++++++++ tests/components/volvo/fixtures/windows.json | 22 +++ .../fixtures/xc40_electric_2024/vehicle.json | 17 +++ .../fixtures/xc90_petrol_2019/commands.json | 44 ++++++ .../fixtures/xc90_petrol_2019/vehicle.json | 16 ++ tests/components/volvo/test_config_flow.py | 29 ++-- tests/components/volvo/test_sensor.py | 92 +++++++++++ 32 files changed, 789 insertions(+), 46 deletions(-) create mode 100644 tests/components/volvo/common.py create mode 100644 tests/components/volvo/fixtures/availability.json create mode 100644 tests/components/volvo/fixtures/brakes.json create mode 100644 tests/components/volvo/fixtures/commands.json create mode 100644 tests/components/volvo/fixtures/diagnostics.json create mode 100644 tests/components/volvo/fixtures/doors.json create mode 100644 tests/components/volvo/fixtures/engine_status.json create mode 100644 tests/components/volvo/fixtures/engine_status_no_timestamp.json create mode 100644 tests/components/volvo/fixtures/engine_warnings.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/vehicle.json create mode 100644 tests/components/volvo/fixtures/ex30_2024_no_colour/vehicle.json create mode 100644 tests/components/volvo/fixtures/fuel_status.json create mode 100644 tests/components/volvo/fixtures/location.json create mode 100644 tests/components/volvo/fixtures/location_no_timestamp.json create mode 100644 tests/components/volvo/fixtures/odometer.json create mode 100644 tests/components/volvo/fixtures/recharge_status.json create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json create mode 100644 tests/components/volvo/fixtures/statistics.json create mode 100644 tests/components/volvo/fixtures/tyres.json create mode 100644 tests/components/volvo/fixtures/warnings.json create mode 100644 tests/components/volvo/fixtures/windows.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json create mode 100644 tests/components/volvo/fixtures/xc90_petrol_2019/commands.json create mode 100644 tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json create mode 100644 tests/components/volvo/test_sensor.py diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py index 43aea367dca4b4..7891c99638d152 100644 --- a/homeassistant/components/volvo/const.py +++ b/homeassistant/components/volvo/const.py @@ -5,6 +5,8 @@ DOMAIN = "volvo" PLATFORMS: list[Platform] = [Platform.SENSOR] +ATTR_API_TIMESTAMP = "api_timestamp" + CONF_VIN = "vin" DATA_BATTERY_CAPACITY = "battery_capacity_kwh" diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py index 5108e756db140b..fc35f4331e1227 100644 --- a/homeassistant/components/volvo/entity.py +++ b/homeassistant/components/volvo/entity.py @@ -1,14 +1,15 @@ """Volvo entity classes.""" +from abc import ABC, abstractmethod from dataclasses import dataclass -from volvocarsapi.models import VolvoCarsApiBaseModel +from volvocarsapi.models import VolvoCarsApiBaseModel, VolvoCarsValueField from homeassistant.core import callback from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_VIN, DOMAIN +from .const import ATTR_API_TIMESTAMP, CONF_VIN, DOMAIN from .coordinator import VolvoDataCoordinator @@ -29,7 +30,7 @@ class VolvoEntityDescription(EntityDescription): api_field: str -class VolvoEntity(CoordinatorEntity[VolvoDataCoordinator]): +class VolvoEntity(ABC, CoordinatorEntity[VolvoDataCoordinator]): """Volvo base entity.""" _attr_has_entity_name = True @@ -43,12 +44,11 @@ def __init__( super().__init__(coordinator) self.entity_description: VolvoEntityDescription = description - self._attr_unique_id = get_unique_id( coordinator.config_entry.data[CONF_VIN], description.key ) - self._attr_device_info = coordinator.device + self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -61,9 +61,13 @@ def _handle_coordinator_update(self) -> None: api_field = self.coordinator.get_api_field(self.entity_description.api_field) self._attr_available = super().available and api_field is not None - self._update_state(api_field) + if isinstance(api_field, VolvoCarsValueField): + self._attr_extra_state_attributes[ATTR_API_TIMESTAMP] = api_field.timestamp + + self._update_state(api_field) super()._handle_coordinator_update() + @abstractmethod def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: - pass + raise NotImplementedError diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index 047468a7bbc49b..1b69ae6822815a 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -389,8 +389,7 @@ def __init__( ) def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: - if not isinstance(api_field, VolvoCarsValue): - return + assert isinstance(api_field, VolvoCarsValue) native_value = ( api_field.value diff --git a/tests/components/volvo/common.py b/tests/components/volvo/common.py new file mode 100644 index 00000000000000..ae50b89bc8842e --- /dev/null +++ b/tests/components/volvo/common.py @@ -0,0 +1,30 @@ +"""Common test utils for Volvo.""" + +from functools import lru_cache +import pathlib + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.util.json import JsonObjectType, json_loads_object + + +@lru_cache +def load_json_object_fixture(name: str, model: str | None = None) -> JsonObjectType: + """Load a JSON object from a fixture.""" + + name = f"{name}.json" + + fixtures_path = ( + pathlib.Path().cwd().joinpath("tests", "components", DOMAIN, "fixtures") + ) + + # If a model is given, check if there the requested data + # is available. If not, fallback to the fixtures root. + path = fixtures_path.joinpath(model) if model else fixtures_path + data_path = path.joinpath(name) + + if data_path.exists(): + fixture = data_path.read_text(encoding="utf8") + else: + fixture = fixtures_path.joinpath(name).read_text(encoding="utf8") + + return json_loads_object(fixture) diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py index c057ec7998d8b9..d07027bbf56260 100644 --- a/tests/components/volvo/conftest.py +++ b/tests/components/volvo/conftest.py @@ -1,10 +1,13 @@ -"""Define fixtures for volvo unit tests.""" +"""Define fixtures for Volvo unit tests.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, Mock, patch +from _pytest.fixtures import SubRequest import pytest +from volvocarsapi.api import VolvoCarsApi from volvocarsapi.auth import AUTHORIZE_URL +from volvocarsapi.models import VolvoCarsValueField, VolvoCarsVehicle from yarl import URL from homeassistant import config_entries @@ -13,18 +16,131 @@ async_import_client_credential, ) from homeassistant.components.volvo.const import CONF_VIN, DOMAIN, SCOPES +from homeassistant.components.volvo.coordinator import VolvoData from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component +from .common import load_json_object_fixture from .const import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator +def pytest_configure(config: pytest.Config): + """Configure pytest.""" + config.addinivalue_line("markers", "use_model(name): mark test to use given model") + + +def model(name: str): + """Define which model to use when running the test. Use as a decorator.""" + return pytest.mark.use_model(name) + + +@pytest.fixture +def model_from_marker(request: SubRequest) -> str: # pylint: disable=hass-argument-type + """Get model from marker.""" + marker = request.node.get_closest_marker("use_model") + return marker.args[0] if marker is not None else "xc40_electric_2024" + + @pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="YV1ABCDEFG1234567", + data={ + "auth_implementation": DOMAIN, + CONF_API_KEY: "abcdef0123456879abcdef", + CONF_VIN: "YV1ABCDEFG1234567", + CONF_TOKEN: {CONF_ACCESS_TOKEN: "mock-access-token"}, + }, + ) + + config_entry.runtime_data = VolvoData(Mock()) + config_entry.add_to_hass(hass) + + return config_entry + + +@pytest.fixture(autouse=True) +async def mock_api(model_from_marker: str) -> AsyncGenerator[VolvoCarsApi]: + """Mock API.""" + + # marker = request.node.get_closest_marker("use_model") + # model = marker.args[0] if marker is not None else "xc40_electric_2024" + model = model_from_marker + + with ( + # patch.object(VolvoCarsAuthApi, "async_refresh_token") as mock_auth_api, + patch( + "homeassistant.components.volvo.VolvoCarsApi", + spec_set=VolvoCarsApi, + ) as mock_api, + # patch( + # "homeassistant.components.volvo.config_flow.VolvoCarsApi", + # new=mock_api, + # ), + ): + vehicle_data = load_json_object_fixture("vehicle", model) + vehicle = VolvoCarsVehicle.from_dict(vehicle_data) + + # commands_data = load_json_object_fixture("commands", model).get("data") + # commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] # type: ignore[arg-type, union-attr] + + # location_data = load_json_object_fixture("location", model) + # location = {"location": VolvoCarsLocation.from_dict(location_data)} + + availability = _get_json_as_value_field("availability", model) + # brakes = _get_json_as_value_field("brakes", model) + diagnostics = _get_json_as_value_field("diagnostics", model) + # doors = _get_json_as_value_field("doors", model) + # engine_status = _get_json_as_value_field("engine_status", model) + # engine_warnings = _get_json_as_value_field("engine_warnings", model) + fuel_status = _get_json_as_value_field("fuel_status", model) + odometer = _get_json_as_value_field("odometer", model) + recharge_status = _get_json_as_value_field("recharge_status", model) + statistics = _get_json_as_value_field("statistics", model) + # tyres = _get_json_as_value_field("tyres", model) + # warnings = _get_json_as_value_field("warnings", model) + # windows = _get_json_as_value_field("windows", model) + + api: VolvoCarsApi = mock_api.return_value + # api.async_get_brakes_status = AsyncMock(return_value=brakes) + api.async_get_command_accessibility = AsyncMock(return_value=availability) + # api.async_get_commands = AsyncMock(return_value=commands) + api.async_get_diagnostics = AsyncMock(return_value=diagnostics) + # api.async_get_doors_status = AsyncMock(return_value=doors) + # api.async_get_engine_status = AsyncMock(return_value=engine_status) + # api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings) + api.async_get_fuel_status = AsyncMock(return_value=fuel_status) + # api.async_get_location = AsyncMock(return_value=location) + api.async_get_odometer = AsyncMock(return_value=odometer) + api.async_get_recharge_status = AsyncMock(return_value=recharge_status) + api.async_get_statistics = AsyncMock(return_value=statistics) + # api.async_get_tyre_states = AsyncMock(return_value=tyres) + api.async_get_vehicle_details = AsyncMock(return_value=vehicle) + # api.async_get_warnings = AsyncMock(return_value=warnings) + # api.async_get_window_states = AsyncMock(return_value=windows) + + # mock_auth_api.return_value = AuthorizationModel( + # "COMPLETED", + # token=TokenResponse( + # access_token="", + # refresh_token="", + # token_type="Bearer", + # expires_in=1799, + # id_token="", + # ), + # ) + + yield api + + +@pytest.fixture(autouse=True) async def setup_credentials(hass: HomeAssistant) -> None: """Fixture to setup credentials.""" assert await async_setup_component(hass, "application_credentials", {}) @@ -39,7 +155,6 @@ async def setup_credentials(hass: HomeAssistant) -> None: async def config_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - setup_credentials: None, ) -> config_entries.ConfigFlowResult: """Initialize a new config flow.""" result = await hass.config_entries.flow.async_init( @@ -71,22 +186,6 @@ async def config_flow( return result -@pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: - """Return the default mocked config entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="YV123456789", - data={ - CONF_API_KEY: "abcdef0123456879abcdef", - CONF_VIN: "YV123456789", - CONF_TOKEN: {CONF_ACCESS_TOKEN: "mock-access-token"}, - }, - ) - config_entry.add_to_hass(hass) - return config_entry - - @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -94,3 +193,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.volvo.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +def _get_json_as_value_field(name: str, model: str) -> dict[str, VolvoCarsValueField]: + data = load_json_object_fixture(name, model) + return {key: VolvoCarsValueField.from_dict(value) for key, value in data.items()} diff --git a/tests/components/volvo/const.py b/tests/components/volvo/const.py index c64122aea4f06a..13a8ff0dd0316c 100644 --- a/tests/components/volvo/const.py +++ b/tests/components/volvo/const.py @@ -1,4 +1,4 @@ -"""Define const for volvo unit tests.""" +"""Define const for Volvo unit tests.""" CLIENT_ID = "1234" CLIENT_SECRET = "5678" diff --git a/tests/components/volvo/fixtures/availability.json b/tests/components/volvo/fixtures/availability.json new file mode 100644 index 00000000000000..264f4d543600f3 --- /dev/null +++ b/tests/components/volvo/fixtures/availability.json @@ -0,0 +1,6 @@ +{ + "availabilityStatus": { + "value": "AVAILABLE", + "timestamp": "2024-12-30T14:32:26.169Z" + } +} diff --git a/tests/components/volvo/fixtures/brakes.json b/tests/components/volvo/fixtures/brakes.json new file mode 100644 index 00000000000000..6fe3b3b328c3a3 --- /dev/null +++ b/tests/components/volvo/fixtures/brakes.json @@ -0,0 +1,6 @@ +{ + "brakeFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/commands.json b/tests/components/volvo/fixtures/commands.json new file mode 100644 index 00000000000000..5d21861801ff6b --- /dev/null +++ b/tests/components/volvo/fixtures/commands.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "command": "LOCK_REDUCED_GUARD", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock-reduced-guard" + }, + { + "command": "LOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock" + }, + { + "command": "UNLOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/unlock" + }, + { + "command": "HONK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk" + }, + { + "command": "HONK_AND_FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk-flash" + }, + { + "command": "FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/flash" + }, + { + "command": "CLIMATIZATION_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-start" + }, + { + "command": "CLIMATIZATION_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-stop" + } + ] +} diff --git a/tests/components/volvo/fixtures/diagnostics.json b/tests/components/volvo/fixtures/diagnostics.json new file mode 100644 index 00000000000000..100af71b9e3581 --- /dev/null +++ b/tests/components/volvo/fixtures/diagnostics.json @@ -0,0 +1,25 @@ +{ + "serviceWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineHoursToService": { + "value": 1266, + "unit": "h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToService": { + "value": 29000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "washerFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "timeToService": { + "value": 23, + "unit": "months", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/doors.json b/tests/components/volvo/fixtures/doors.json new file mode 100644 index 00000000000000..268d9fec4679a3 --- /dev/null +++ b/tests/components/volvo/fixtures/doors.json @@ -0,0 +1,34 @@ +{ + "centralLock": { + "value": "LOCKED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "frontLeftDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "frontRightDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "rearLeftDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "rearRightDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "hood": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "tailgate": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "tankLid": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + } +} diff --git a/tests/components/volvo/fixtures/engine_status.json b/tests/components/volvo/fixtures/engine_status.json new file mode 100644 index 00000000000000..daac36b6a26c2e --- /dev/null +++ b/tests/components/volvo/fixtures/engine_status.json @@ -0,0 +1,6 @@ +{ + "engineStatus": { + "value": "STOPPED", + "timestamp": "2024-12-30T15:00:00.000Z" + } +} diff --git a/tests/components/volvo/fixtures/engine_status_no_timestamp.json b/tests/components/volvo/fixtures/engine_status_no_timestamp.json new file mode 100644 index 00000000000000..13101f5a776bcd --- /dev/null +++ b/tests/components/volvo/fixtures/engine_status_no_timestamp.json @@ -0,0 +1,5 @@ +{ + "engineStatus": { + "value": "STOPPED" + } +} diff --git a/tests/components/volvo/fixtures/engine_warnings.json b/tests/components/volvo/fixtures/engine_warnings.json new file mode 100644 index 00000000000000..d431355fd242d4 --- /dev/null +++ b/tests/components/volvo/fixtures/engine_warnings.json @@ -0,0 +1,10 @@ +{ + "oilLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineCoolantLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/vehicle.json b/tests/components/volvo/fixtures/ex30_2024/vehicle.json new file mode 100644 index 00000000000000..dc47b5bb341bbf --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "NONE", + "externalColour": "Crystal White Pearl", + "batteryCapacityKWH": 66.0, + "images": { + "exteriorImageUrl": "https://wizz.volvocars.com/images/2024/123/v2/exterior/studio/right/transparent_exterior-studio-right_0000000000000000000000000000000000000000.png?client=public-api-engineering&w=1920", + "internalImageUrl": "https://wizz.volvocars.com/images/2024/123/v2/interior/studio/side/interior-studio-side_0000000000000000000000000000000000000000.png?client=public-api-engineering&w=1920" + }, + "descriptions": { + "model": "EX30", + "upholstery": "R310", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024_no_colour/vehicle.json b/tests/components/volvo/fixtures/ex30_2024_no_colour/vehicle.json new file mode 100644 index 00000000000000..cda3b892e15a59 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024_no_colour/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "NONE", + "batteryCapacityKWH": 69.0, + "images": { + "exteriorImageUrl": "https://wizz.volvocars.com/images/2024/416/v2/exterior/studio/right/transparent_exterior-studio-right_87de52fdb39d5303af0b9accb2ffa02ffb1d640e.png?client=public-api-engineering&w=1920", + "internalImageUrl": "https://wizz.volvocars.com/images/2024/416/v2/interior/studio/side/interior-studio-side_0c5b82913a907ff4ba517506060b29f883c10062.png?client=public-api-engineering&w=1920" + }, + "descriptions": { + "model": "EX30", + "upholstery": "R540", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/fuel_status.json b/tests/components/volvo/fixtures/fuel_status.json new file mode 100644 index 00000000000000..a55f14467fe2ae --- /dev/null +++ b/tests/components/volvo/fixtures/fuel_status.json @@ -0,0 +1,12 @@ +{ + "fuelAmount": { + "value": "47.3", + "unit": "l", + "timestamp": "2020-11-19T21:23:24.123Z" + }, + "batteryChargeLevel": { + "value": "87.3", + "unit": "%", + "timestamp": "2020-11-19T21:23:24.123Z" + } +} diff --git a/tests/components/volvo/fixtures/location.json b/tests/components/volvo/fixtures/location.json new file mode 100644 index 00000000000000..eec49f8a66bd84 --- /dev/null +++ b/tests/components/volvo/fixtures/location.json @@ -0,0 +1,11 @@ +{ + "type": "Feature", + "properties": { + "timestamp": "2024-12-30T15:00:00.000Z", + "heading": "90" + }, + "geometry": { + "type": "Point", + "coordinates": [11.849843629550225, 57.72537482589284, 0.0] + } +} diff --git a/tests/components/volvo/fixtures/location_no_timestamp.json b/tests/components/volvo/fixtures/location_no_timestamp.json new file mode 100644 index 00000000000000..0c9e918657964d --- /dev/null +++ b/tests/components/volvo/fixtures/location_no_timestamp.json @@ -0,0 +1,10 @@ +{ + "type": "Feature", + "properties": { + "heading": "90" + }, + "geometry": { + "type": "Point", + "coordinates": [11.849843629550225, 57.72537482589284, 0.0] + } +} diff --git a/tests/components/volvo/fixtures/odometer.json b/tests/components/volvo/fixtures/odometer.json new file mode 100644 index 00000000000000..a9196faaa7de49 --- /dev/null +++ b/tests/components/volvo/fixtures/odometer.json @@ -0,0 +1,7 @@ +{ + "odometer": { + "value": 30000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/recharge_status.json b/tests/components/volvo/fixtures/recharge_status.json new file mode 100644 index 00000000000000..5e9fed0803c64f --- /dev/null +++ b/tests/components/volvo/fixtures/recharge_status.json @@ -0,0 +1,25 @@ +{ + "estimatedChargingTime": { + "value": "780", + "unit": "minutes", + "timestamp": "2024-12-30T14:30:08Z" + }, + "batteryChargeLevel": { + "value": "58.0", + "unit": "percentage", + "timestamp": "2024-12-30T14:30:08Z" + }, + "electricRange": { + "value": "250", + "unit": "kilometers", + "timestamp": "2024-12-30T14:30:08Z" + }, + "chargingSystemStatus": { + "value": "CHARGING_SYSTEM_IDLE", + "timestamp": "2024-12-30T14:30:08Z" + }, + "chargingConnectionStatus": { + "value": "CONNECTION_STATUS_CONNECTED_AC", + "timestamp": "2024-12-30T14:30:08Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json b/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json new file mode 100644 index 00000000000000..738eb3c8966042 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json @@ -0,0 +1,25 @@ +{ + "serviceWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineHoursToService": { + "value": 1266, + "unit": "h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToService": { + "value": 29000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "washerFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "timeToService": { + "value": 17, + "unit": "days", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json b/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json new file mode 100644 index 00000000000000..429964991e76e5 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2018, + "gearbox": "AUTOMATIC", + "fuelType": "DIESEL", + "externalColour": "Electric Silver", + "images": { + "exteriorImageUrl": "", + "internalImageUrl": "" + }, + "descriptions": { + "model": "S90", + "upholstery": "null", + "steering": "RIGHT" + } +} diff --git a/tests/components/volvo/fixtures/statistics.json b/tests/components/volvo/fixtures/statistics.json new file mode 100644 index 00000000000000..47b72320c4fc37 --- /dev/null +++ b/tests/components/volvo/fixtures/statistics.json @@ -0,0 +1,37 @@ +{ + "averageEnergyConsumption": { + "value": 22.6, + "unit": "kWh/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageFuelConsumption": { + "value": 7.23, + "unit": "l/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyBattery": { + "value": 250, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + } +} diff --git a/tests/components/volvo/fixtures/tyres.json b/tests/components/volvo/fixtures/tyres.json new file mode 100644 index 00000000000000..c414c85203f96a --- /dev/null +++ b/tests/components/volvo/fixtures/tyres.json @@ -0,0 +1,18 @@ +{ + "frontLeft": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "frontRight": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "rearLeft": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "rearRight": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/warnings.json b/tests/components/volvo/fixtures/warnings.json new file mode 100644 index 00000000000000..5bec30ed4b3aa5 --- /dev/null +++ b/tests/components/volvo/fixtures/warnings.json @@ -0,0 +1,94 @@ +{ + "brakeLightCenterWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "brakeLightLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "brakeLightRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "fogLightFrontWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "fogLightRearWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightFrontLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightFrontRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightRearLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightRearRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "highBeamLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "highBeamRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "lowBeamLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "lowBeamRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "daytimeRunningLightLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "daytimeRunningLightRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationFrontLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationFrontRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationRearLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationRearRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "registrationPlateLightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "sideMarkLightsWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "hazardLightsWarning": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "reverseLightsWarning": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/windows.json b/tests/components/volvo/fixtures/windows.json new file mode 100644 index 00000000000000..cd399b3bbe8f08 --- /dev/null +++ b/tests/components/volvo/fixtures/windows.json @@ -0,0 +1,22 @@ +{ + "frontLeftWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "frontRightWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "rearLeftWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "rearRightWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "sunroof": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:28:12.202Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json b/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json new file mode 100644 index 00000000000000..8b36c06f681f78 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "ELECTRIC", + "externalColour": "Silver Dawn", + "batteryCapacityKWH": 81.608, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/exterior-v4/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/interior-v4/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC40", + "upholstery": "null", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json b/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json new file mode 100644 index 00000000000000..8f5e62df1edf44 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json @@ -0,0 +1,44 @@ +{ + "data": [ + { + "command": "LOCK_REDUCED_GUARD", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock-reduced-guard" + }, + { + "command": "LOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock" + }, + { + "command": "UNLOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/unlock" + }, + { + "command": "HONK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk" + }, + { + "command": "HONK_AND_FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk-flash" + }, + { + "command": "FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/flash" + }, + { + "command": "CLIMATIZATION_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-start" + }, + { + "command": "CLIMATIZATION_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-stop" + }, + { + "command": "ENGINE_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/engine-start" + }, + { + "command": "ENGINE_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/engine-stop" + } + ] +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json b/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json new file mode 100644 index 00000000000000..1d4b1250b8a335 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2019, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL", + "externalColour": "Passion Red Solid", + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/vbsnext-v4/exterior/MY17_0000/123/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/vbsnext-v4/interior/MY17_0000/123/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC90", + "upholstery": "CHARCOAL/LEABR/CHARC/S", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py index 56d0ed54cfae8a..6c895fae1d1c30 100644 --- a/tests/components/volvo/test_config_flow.py +++ b/tests/components/volvo/test_config_flow.py @@ -1,9 +1,9 @@ """Test the Volvo config flow.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock import pytest -from volvocarsapi.api import _API_CONNECTED_ENDPOINT, _API_URL +from volvocarsapi.api import _API_CONNECTED_ENDPOINT, _API_URL, VolvoCarsApi from volvocarsapi.auth import TOKEN_URL from volvocarsapi.models import VolvoApiException @@ -15,6 +15,7 @@ OPT_FUEL_UNIT_MPG_UK, OPT_FUEL_UNIT_MPG_US, ) +from homeassistant.components.volvo.coordinator import VolvoData from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -22,6 +23,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem +from .conftest import model from .const import REDIRECT_URI from tests.common import METRIC_SYSTEM, MockConfigEntry @@ -85,7 +87,7 @@ async def test_single_vin_flow( aioclient_mock.get( f"{_API_URL}{_API_CONNECTED_ENDPOINT}", json={ - "data": [{"vin": "YV123456789"}], + "data": [{"vin": "YV1ABCDEFG1234567"}], }, ) @@ -101,7 +103,7 @@ async def test_single_vin_flow( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("current_request_with_host", "setup_credentials") +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -148,7 +150,7 @@ async def test_reconfigure_flow( aioclient_mock.get( f"{_API_URL}{_API_CONNECTED_ENDPOINT}", json={ - "data": [{"vin": "YV123456789"}, {"vin": "YV198765432"}], + "data": [{"vin": "YV1ABCDEFG1234567"}, {"vin": "YV10000000AAAAAAA"}], }, ) @@ -222,8 +224,6 @@ async def test_options_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, fuel_unit: str ) -> None: """Test options flow.""" - mock_config_entry.runtime_data = AsyncMock() - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -235,16 +235,13 @@ async def test_options_flow( assert mock_config_entry.options[OPT_FUEL_CONSUMPTION_UNIT] == fuel_unit +@model("xc40_electric_2024") async def test_no_options_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: VolvoCarsApi ) -> None: """Test options flow where no options are available.""" - mock_config_entry.runtime_data = AsyncMock() - mock_config_entry.runtime_data.coordinator = AsyncMock() - mock_config_entry.runtime_data.coordinator.vehicle = AsyncMock() - mock_config_entry.runtime_data.coordinator.vehicle.has_combustion_engine = Mock( - return_value=False - ) + volvo_data: VolvoData = mock_config_entry.runtime_data + volvo_data.coordinator.vehicle = await mock_api.async_get_vehicle_details() result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.ABORT @@ -271,7 +268,7 @@ async def _async_run_flow_to_completion( aioclient_mock.get( f"{_API_URL}{_API_CONNECTED_ENDPOINT}", json={ - "data": [{"vin": "YV123456789"}, {"vin": "YV198765432"}], + "data": [{"vin": "YV1ABCDEFG1234567"}, {"vin": "YV10000000AAAAAAA"}], }, ) @@ -288,7 +285,7 @@ async def _async_run_flow_to_completion( assert config_flow["step_id"] == "vin" config_flow = await hass.config_entries.flow.async_configure( - config_flow["flow_id"], {CONF_VIN: "YV123456789"} + config_flow["flow_id"], {CONF_VIN: "YV1ABCDEFG1234567"} ) return config_flow diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py new file mode 100644 index 00000000000000..5ef0cc0c3c0596 --- /dev/null +++ b/tests/components/volvo/test_sensor.py @@ -0,0 +1,92 @@ +"""Test Volvo sensors.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.volvo.const import ( + OPT_FUEL_CONSUMPTION_UNIT, + OPT_FUEL_UNIT_LITER_PER_100KM, + OPT_FUEL_UNIT_MPG_UK, + OPT_FUEL_UNIT_MPG_US, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import model + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("unit", "value", "unit_of_measurement"), + [ + ( + OPT_FUEL_UNIT_LITER_PER_100KM, + "7.2", + "L/100 km", + ), + ( + OPT_FUEL_UNIT_MPG_UK, + "39.07", + "mpg", + ), + ( + OPT_FUEL_UNIT_MPG_US, + "32.53", + "mpg", + ), + ], +) +@model("xc90_petrol_2019") +async def test_fuel_unit_conversion( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + unit: str, + value: str, + unit_of_measurement: str, +) -> None: + """Test fuel unit conversion.""" + + entity_id = "sensor.volvo_xc90_petrol_2019_tm_avg_fuel_consumption" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + hass.config_entries.async_update_entry( + mock_config_entry, + options={OPT_FUEL_CONSUMPTION_UNIT: unit}, + ) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity + assert entity.state == value + assert entity.attributes.get("unit_of_measurement") == unit_of_measurement + + +@pytest.mark.parametrize( + ("expected_state"), + [ + pytest.param(23 * 30, marks=model("xc40_electric_2024")), + pytest.param(17, marks=model("s90_diesel_2018")), + ], +) +async def test_time_to_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + model_from_marker: str, + expected_state: int, +) -> None: + """Test time to service.""" + + entity_id = f"sensor.volvo_{model_from_marker}_time_to_service" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity + assert entity.state == f"{expected_state}" From d25ce9bc67069f54a11d0005fcc8ce54ad5955ac Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:56:21 +0000 Subject: [PATCH 04/12] Add unit tests --- homeassistant/components/volvo/__init__.py | 12 +- homeassistant/components/volvo/const.py | 5 - homeassistant/components/volvo/coordinator.py | 6 +- homeassistant/components/volvo/manifest.json | 2 +- .../components/volvo/quality_scale.yaml | 16 +- homeassistant/components/volvo/strings.json | 8 +- tests/components/volvo/conftest.py | 157 ++++++++++++------ tests/components/volvo/const.py | 9 + tests/components/volvo/test_config_flow.py | 29 +--- tests/components/volvo/test_coordinator.py | 94 +++++++++++ tests/components/volvo/test_init.py | 89 ++++++++++ tests/components/volvo/test_sensor.py | 10 +- 12 files changed, 333 insertions(+), 104 deletions(-) create mode 100644 tests/components/volvo/test_coordinator.py create mode 100644 tests/components/volvo/test_init.py diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index 44da7833194a20..e71affbc52996e 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -4,10 +4,12 @@ import logging +from aiohttp import ClientResponseError from volvocarsapi.api import VolvoCarsApi from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .api import VolvoAuth @@ -30,8 +32,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) web_session = aiohttp_client.async_get_clientsession(hass) - auth = VolvoAuth(web_session, oauth_session) + + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if err.status == 401: + raise ConfigEntryAuthFailed from err + + raise ConfigEntryNotReady from err + api = VolvoCarsApi( web_session, auth, diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py index 7891c99638d152..de52344b34165b 100644 --- a/homeassistant/components/volvo/const.py +++ b/homeassistant/components/volvo/const.py @@ -28,16 +28,12 @@ "conve:diagnostics_engine_status", "conve:diagnostics_workshop", "conve:doors_status", - "conve:engine_start_stop", "conve:engine_status", - "conve:honk_flash", "conve:fuel_status", - "conve:lock", "conve:lock_status", "conve:odometer_status", "conve:trip_statistics", "conve:tyre_status", - "conve:unlock", "conve:vehicle_relation", "conve:warnings", "conve:windows_status", @@ -49,5 +45,4 @@ "energy:estimated_charging_time", "energy:recharge_status", "energy:target_battery_level", - "location:read", ] diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index 42a8c51af23609..d4f1bce2c1bf5d 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_VIN, DATA_BATTERY_CAPACITY, DOMAIN, MANUFACTURER +from .const import DATA_BATTERY_CAPACITY, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -35,10 +35,6 @@ class VolvoData: coordinator: VolvoDataCoordinator - def vin(self) -> str | None: - """Vin.""" - return self.coordinator.config_entry.data.get(CONF_VIN) - type VolvoConfigEntry = ConfigEntry[VolvoData] type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None] diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json index de1d9f7c70c67a..a4676f9483f462 100644 --- a/homeassistant/components/volvo/manifest.json +++ b/homeassistant/components/volvo/manifest.json @@ -8,6 +8,6 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["volvocarsapi"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["volvocarsapi==0.0.2b0"] } diff --git a/homeassistant/components/volvo/quality_scale.yaml b/homeassistant/components/volvo/quality_scale.yaml index 0cbed9862fe24d..544b64e964d503 100644 --- a/homeassistant/components/volvo/quality_scale.yaml +++ b/homeassistant/components/volvo/quality_scale.yaml @@ -31,14 +31,14 @@ rules: comment: | The integration does not provide any additional actions. config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done @@ -51,12 +51,12 @@ rules: status: exempt comment: | No discovery possible. - docs-data-update: todo + docs-data-update: done docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done docs-use-cases: todo dynamic-devices: status: exempt diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 34e75c90894dec..9c3beeca7b44dc 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -18,9 +18,8 @@ "api_key": "API key" }, "data_description": { - "api_key": "The Volvo developers API key (https://developer.volvocars.com/)" - }, - "description": "API" + "api_key": "The Volvo developers API key" + } }, "vin": { "data": { @@ -28,8 +27,7 @@ }, "data_description": { "vin": "The Vehicle Identification Number of the vehicle you want to add" - }, - "description": "Vehicle" + } } }, "abort": { diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py index d07027bbf56260..c483c1e98b12f5 100644 --- a/tests/components/volvo/conftest.py +++ b/tests/components/volvo/conftest.py @@ -1,13 +1,19 @@ """Define fixtures for Volvo unit tests.""" -from collections.abc import AsyncGenerator, Generator +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator from unittest.mock import AsyncMock, Mock, patch from _pytest.fixtures import SubRequest import pytest from volvocarsapi.api import VolvoCarsApi -from volvocarsapi.auth import AUTHORIZE_URL -from volvocarsapi.models import VolvoCarsValueField, VolvoCarsVehicle +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL +from volvocarsapi.models import ( + VolvoApiException, + VolvoCarsAvailableCommand, + VolvoCarsLocation, + VolvoCarsValueField, + VolvoCarsVehicle, +) from yarl import URL from homeassistant import config_entries @@ -17,15 +23,22 @@ ) from homeassistant.components.volvo.const import CONF_VIN, DOMAIN, SCOPES from homeassistant.components.volvo.coordinator import VolvoData -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_TOKEN +from homeassistant.const import CONF_API_KEY, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component from .common import load_json_object_fixture -from .const import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI +from .const import ( + CLIENT_ID, + CLIENT_SECRET, + MOCK_ACCESS_TOKEN, + REDIRECT_URI, + SERVER_TOKEN_RESPONSE, +) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -56,7 +69,11 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "auth_implementation": DOMAIN, CONF_API_KEY: "abcdef0123456879abcdef", CONF_VIN: "YV1ABCDEFG1234567", - CONF_TOKEN: {CONF_ACCESS_TOKEN: "mock-access-token"}, + CONF_TOKEN: { + "access_token": MOCK_ACCESS_TOKEN, + "refresh_token": "mock-refresh-token", + "expires_at": 123456789, + }, }, ) @@ -67,77 +84,90 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture(autouse=True) -async def mock_api(model_from_marker: str) -> AsyncGenerator[VolvoCarsApi]: - """Mock API.""" - - # marker = request.node.get_closest_marker("use_model") - # model = marker.args[0] if marker is not None else "xc40_electric_2024" +async def mock_api(model_from_marker: str) -> AsyncGenerator[AsyncMock]: + """Mock the Volvo API.""" model = model_from_marker - with ( - # patch.object(VolvoCarsAuthApi, "async_refresh_token") as mock_auth_api, - patch( - "homeassistant.components.volvo.VolvoCarsApi", - spec_set=VolvoCarsApi, - ) as mock_api, - # patch( - # "homeassistant.components.volvo.config_flow.VolvoCarsApi", - # new=mock_api, - # ), - ): + with patch( + "homeassistant.components.volvo.VolvoCarsApi", + spec_set=VolvoCarsApi, + ) as mock_api: vehicle_data = load_json_object_fixture("vehicle", model) vehicle = VolvoCarsVehicle.from_dict(vehicle_data) - # commands_data = load_json_object_fixture("commands", model).get("data") - # commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] # type: ignore[arg-type, union-attr] + commands_data = load_json_object_fixture("commands", model).get("data") + commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] # type: ignore[arg-type, union-attr] - # location_data = load_json_object_fixture("location", model) - # location = {"location": VolvoCarsLocation.from_dict(location_data)} + location_data = load_json_object_fixture("location", model) + location = {"location": VolvoCarsLocation.from_dict(location_data)} availability = _get_json_as_value_field("availability", model) - # brakes = _get_json_as_value_field("brakes", model) + brakes = _get_json_as_value_field("brakes", model) diagnostics = _get_json_as_value_field("diagnostics", model) - # doors = _get_json_as_value_field("doors", model) - # engine_status = _get_json_as_value_field("engine_status", model) - # engine_warnings = _get_json_as_value_field("engine_warnings", model) + doors = _get_json_as_value_field("doors", model) + engine_status = _get_json_as_value_field("engine_status", model) + engine_warnings = _get_json_as_value_field("engine_warnings", model) fuel_status = _get_json_as_value_field("fuel_status", model) odometer = _get_json_as_value_field("odometer", model) recharge_status = _get_json_as_value_field("recharge_status", model) statistics = _get_json_as_value_field("statistics", model) - # tyres = _get_json_as_value_field("tyres", model) - # warnings = _get_json_as_value_field("warnings", model) - # windows = _get_json_as_value_field("windows", model) + tyres = _get_json_as_value_field("tyres", model) + warnings = _get_json_as_value_field("warnings", model) + windows = _get_json_as_value_field("windows", model) api: VolvoCarsApi = mock_api.return_value - # api.async_get_brakes_status = AsyncMock(return_value=brakes) + api.async_get_brakes_status = AsyncMock(return_value=brakes) api.async_get_command_accessibility = AsyncMock(return_value=availability) - # api.async_get_commands = AsyncMock(return_value=commands) + api.async_get_commands = AsyncMock(return_value=commands) api.async_get_diagnostics = AsyncMock(return_value=diagnostics) - # api.async_get_doors_status = AsyncMock(return_value=doors) - # api.async_get_engine_status = AsyncMock(return_value=engine_status) - # api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings) + api.async_get_doors_status = AsyncMock(return_value=doors) + api.async_get_engine_status = AsyncMock(return_value=engine_status) + api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings) api.async_get_fuel_status = AsyncMock(return_value=fuel_status) - # api.async_get_location = AsyncMock(return_value=location) + api.async_get_location = AsyncMock(return_value=location) api.async_get_odometer = AsyncMock(return_value=odometer) api.async_get_recharge_status = AsyncMock(return_value=recharge_status) api.async_get_statistics = AsyncMock(return_value=statistics) - # api.async_get_tyre_states = AsyncMock(return_value=tyres) + api.async_get_tyre_states = AsyncMock(return_value=tyres) api.async_get_vehicle_details = AsyncMock(return_value=vehicle) - # api.async_get_warnings = AsyncMock(return_value=warnings) - # api.async_get_window_states = AsyncMock(return_value=windows) + api.async_get_warnings = AsyncMock(return_value=warnings) + api.async_get_window_states = AsyncMock(return_value=windows) - # mock_auth_api.return_value = AuthorizationModel( - # "COMPLETED", - # token=TokenResponse( - # access_token="", - # refresh_token="", - # token_type="Bearer", - # expires_in=1799, - # id_token="", - # ), - # ) + yield mock_api - yield api + +@pytest.fixture +async def mock_api_failure(model_from_marker: str) -> AsyncGenerator[AsyncMock]: + """Mock the Volvo API so that it raises an exception for all calls during coordinator update.""" + model = model_from_marker + vehicle_data = load_json_object_fixture("vehicle", model) + vehicle = VolvoCarsVehicle.from_dict(vehicle_data) + + with patch( + "homeassistant.components.volvo.VolvoCarsApi", + spec_set=VolvoCarsApi, + ) as mock_api: + api: VolvoCarsApi = mock_api.return_value + + api.async_get_vehicle_details = AsyncMock(return_value=vehicle) + + api.async_get_brakes_status = AsyncMock(side_effect=VolvoApiException()) + api.async_get_command_accessibility = AsyncMock(side_effect=VolvoApiException()) + api.async_get_commands = AsyncMock(side_effect=VolvoApiException()) + api.async_get_diagnostics = AsyncMock(side_effect=VolvoApiException()) + api.async_get_doors_status = AsyncMock(side_effect=VolvoApiException()) + api.async_get_engine_status = AsyncMock(side_effect=VolvoApiException()) + api.async_get_engine_warnings = AsyncMock(side_effect=VolvoApiException()) + api.async_get_fuel_status = AsyncMock(side_effect=VolvoApiException()) + api.async_get_location = AsyncMock(side_effect=VolvoApiException()) + api.async_get_odometer = AsyncMock(side_effect=VolvoApiException()) + api.async_get_recharge_status = AsyncMock(side_effect=VolvoApiException()) + api.async_get_statistics = AsyncMock(side_effect=VolvoApiException()) + api.async_get_tyre_states = AsyncMock(side_effect=VolvoApiException()) + api.async_get_warnings = AsyncMock(side_effect=VolvoApiException()) + api.async_get_window_states = AsyncMock(side_effect=VolvoApiException()) + + yield mock_api @pytest.fixture(autouse=True) @@ -186,6 +216,27 @@ async def config_flow( return result +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + + async def run() -> bool: + aioclient_mock.post( + TOKEN_URL, + json=SERVER_TOKEN_RESPONSE, + ) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" diff --git a/tests/components/volvo/const.py b/tests/components/volvo/const.py index 13a8ff0dd0316c..9ccb38ca913e15 100644 --- a/tests/components/volvo/const.py +++ b/tests/components/volvo/const.py @@ -3,4 +3,13 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" +MOCK_ACCESS_TOKEN = "mock-access-token" + REDIRECT_URI = "https://example.com/auth/external/callback" + +SERVER_TOKEN_RESPONSE = { + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", + "token_type": "Bearer", + "expires_in": 60, +} diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py index 6c895fae1d1c30..83253848419d39 100644 --- a/tests/components/volvo/test_config_flow.py +++ b/tests/components/volvo/test_config_flow.py @@ -24,7 +24,7 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem from .conftest import model -from .const import REDIRECT_URI +from .const import REDIRECT_URI, SERVER_TOKEN_RESPONSE from tests.common import METRIC_SYSTEM, MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -76,12 +76,7 @@ async def test_single_vin_flow( """Check flow where API returns a single VIN.""" aioclient_mock.post( TOKEN_URL, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, + json=SERVER_TOKEN_RESPONSE, ) aioclient_mock.get( @@ -186,12 +181,7 @@ async def test_api_failure_flow( """Check flow where API throws an exception.""" aioclient_mock.post( TOKEN_URL, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, + json=SERVER_TOKEN_RESPONSE, ) aioclient_mock.get( @@ -237,11 +227,13 @@ async def test_options_flow( @model("xc40_electric_2024") async def test_no_options_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: VolvoCarsApi + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: AsyncMock ) -> None: """Test options flow where no options are available.""" volvo_data: VolvoData = mock_config_entry.runtime_data - volvo_data.coordinator.vehicle = await mock_api.async_get_vehicle_details() + api: VolvoCarsApi = mock_api.return_value + + volvo_data.coordinator.vehicle = await api.async_get_vehicle_details() result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.ABORT @@ -257,12 +249,7 @@ async def _async_run_flow_to_completion( ) -> ConfigFlowResult: aioclient_mock.post( TOKEN_URL, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "token_type": "Bearer", - "expires_in": 60, - }, + json=SERVER_TOKEN_RESPONSE, ) aioclient_mock.get( diff --git a/tests/components/volvo/test_coordinator.py b/tests/components/volvo/test_coordinator.py new file mode 100644 index 00000000000000..cef592358d6df7 --- /dev/null +++ b/tests/components/volvo/test_coordinator.py @@ -0,0 +1,94 @@ +"""Test Volvo coordinator.""" + +from unittest.mock import AsyncMock + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoApiException, VolvoAuthException + +from homeassistant.components.volvo.coordinator import VolvoDataCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import MockConfigEntry + + +async def test_setup_coordinator_auth_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test auth failure during coordinator setup.""" + + api: VolvoCarsApi = mock_api.return_value + api.async_get_vehicle_details = AsyncMock(side_effect=VolvoAuthException()) + + coordinator = VolvoDataCoordinator(hass, mock_config_entry, api) + + with pytest.raises(ConfigEntryAuthFailed): + await coordinator._async_setup() + + +async def test_setup_coordinator_no_vehicle( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test no vehicle during coordinator setup.""" + + api: VolvoCarsApi = mock_api.return_value + api.async_get_vehicle_details = AsyncMock(return_value=None) + + coordinator = VolvoDataCoordinator(hass, mock_config_entry, api) + + with pytest.raises(HomeAssistantError): + await coordinator._async_setup() + + +async def test_update_coordinator_auth_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test auth failure during coordinator update.""" + + api: VolvoCarsApi = mock_api.return_value + api.async_get_diagnostics = AsyncMock(side_effect=VolvoAuthException()) + + coordinator = VolvoDataCoordinator(hass, mock_config_entry, api) + await coordinator._async_setup() + + with pytest.raises(ConfigEntryAuthFailed): + await coordinator._async_update_data() + + +async def test_update_coordinator_single_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: AsyncMock, +) -> None: + """Test API returns error for single call during coordinator update.""" + + api: VolvoCarsApi = mock_api.return_value + api.async_get_diagnostics = AsyncMock(side_effect=VolvoApiException()) + + coordinator = VolvoDataCoordinator(hass, mock_config_entry, api) + await coordinator._async_setup() + assert await coordinator._async_update_data() + + +async def test_update_coordinator_all_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_failure: AsyncMock, +) -> None: + """Test API returning error for all calls during coordinator update.""" + + coordinator = VolvoDataCoordinator( + hass, mock_config_entry, mock_api_failure.return_value + ) + await coordinator._async_setup() + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() diff --git a/tests/components/volvo/test_init.py b/tests/components/volvo/test_init.py new file mode 100644 index 00000000000000..33be66cf76eea9 --- /dev/null +++ b/tests/components/volvo/test_init.py @@ -0,0 +1,89 @@ +"""Test Volvo init.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus + +import pytest +from volvocarsapi.auth import TOKEN_URL + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .const import MOCK_ACCESS_TOKEN, SERVER_TOKEN_RESPONSE + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test setting up the integration.""" + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + assert await setup_integration() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_token_refresh_success( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test where token refresh succeeds.""" + + assert mock_config_entry.data[CONF_TOKEN]["access_token"] == MOCK_ACCESS_TOKEN + + assert await setup_integration() + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify token + assert len(aioclient_mock.mock_calls) == 1 + assert ( + mock_config_entry.data[CONF_TOKEN]["access_token"] + == SERVER_TOKEN_RESPONSE["access_token"] + ) + + +@pytest.mark.parametrize( + ("token_response"), + [ + (HTTPStatus.BAD_REQUEST), + (HTTPStatus.FORBIDDEN), + (HTTPStatus.INTERNAL_SERVER_ERROR), + (HTTPStatus.NOT_FOUND), + ], +) +async def test_token_refresh_fail( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], + token_response: HTTPStatus, +) -> None: + """Test where token refresh fails.""" + + aioclient_mock.post(TOKEN_URL, status=token_response) + + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_token_refresh_reauth( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test where token refresh indicates unauthorized.""" + + aioclient_mock.post(TOKEN_URL, status=HTTPStatus.UNAUTHORIZED) + + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index 5ef0cc0c3c0596..54406718d39bab 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -1,5 +1,6 @@ """Test Volvo sensors.""" +from collections.abc import Awaitable, Callable from unittest.mock import patch import pytest @@ -42,6 +43,7 @@ async def test_fuel_unit_conversion( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], unit: str, value: str, unit_of_measurement: str, @@ -51,8 +53,7 @@ async def test_fuel_unit_conversion( entity_id = "sensor.volvo_xc90_petrol_2019_tm_avg_fuel_consumption" with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + assert await setup_integration() hass.config_entries.async_update_entry( mock_config_entry, @@ -75,7 +76,7 @@ async def test_fuel_unit_conversion( ) async def test_time_to_service( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], model_from_marker: str, expected_state: int, ) -> None: @@ -84,8 +85,7 @@ async def test_time_to_service( entity_id = f"sensor.volvo_{model_from_marker}_time_to_service" with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + assert await setup_integration() entity = hass.states.get(entity_id) assert entity From f97ce1657e0099b098665eddd3f1605a46234cb9 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:29:54 +0000 Subject: [PATCH 05/12] Code review fixes --- homeassistant/components/volvo/__init__.py | 23 +++++++++---------- homeassistant/components/volvo/api.py | 1 - homeassistant/components/volvo/coordinator.py | 13 +++++------ 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index e71affbc52996e..2ba63d7c25c75e 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -10,7 +10,11 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) from .api import VolvoAuth from .const import CONF_VIN, PLATFORMS @@ -24,14 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo _LOGGER.debug("%s - Loading entry", entry.entry_id) # Create APIs - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ) - ) - - oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - web_session = aiohttp_client.async_get_clientsession(hass) + implementation = await async_get_config_entry_implementation(hass, entry) + oauth_session = OAuth2Session(hass, entry, implementation) + web_session = async_get_clientsession(hass) auth = VolvoAuth(web_session, oauth_session) try: @@ -45,14 +44,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo api = VolvoCarsApi( web_session, auth, - entry.data.get(CONF_VIN, ""), - entry.data.get(CONF_API_KEY, ""), + entry.data[CONF_VIN], + entry.data[CONF_API_KEY], ) # Setup entry coordinator = VolvoDataCoordinator(hass, entry, api) - entry.runtime_data = VolvoData(coordinator) await coordinator.async_config_entry_first_refresh() + entry.runtime_data = VolvoData(coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Register events diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py index f704547b5ad040..e2c1070f1ea528 100644 --- a/homeassistant/components/volvo/api.py +++ b/homeassistant/components/volvo/api.py @@ -8,7 +8,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session -# For more info see the docs at https://developers.home-assistant.io/docs/api_lib_auth/#oauth2. class VolvoAuth(AccessTokenManager): """Provide Volvo authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index d4f1bce2c1bf5d..a61f31a0b18854 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -61,7 +61,7 @@ def __init__( update_interval=timedelta(seconds=135), ) - self.api = api + self.api: VolvoCarsApi = api self.vehicle: VolvoCarsVehicle self.device: DeviceInfo @@ -81,12 +81,6 @@ async def _async_setup(self) -> None: try: vehicle = await self.api.async_get_vehicle_details() - - if vehicle is None: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="no_vehicle" - ) - except VolvoAuthException as ex: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -94,6 +88,11 @@ async def _async_setup(self) -> None: translation_placeholders={"message": ex.message}, ) from ex + if vehicle is None: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="no_vehicle" + ) + self.vehicle = vehicle self.data = self.data or {} From 8a06d932141f08460204bc04381dd547d2f24317 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Thu, 17 Apr 2025 18:06:10 +0000 Subject: [PATCH 06/12] Fix translation issues --- homeassistant/components/volvo/strings.json | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 9c3beeca7b44dc..6cd2b051bfa534 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -15,7 +15,7 @@ }, "api_key": { "data": { - "api_key": "API key" + "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { "api_key": "The Volvo developers API key" @@ -70,7 +70,7 @@ "selector": { "fuel_consumption_unit": { "options": { - "l_100km": "L/100 km", + "l_100km": "l/100 km", "mpg_uk": "mpg (UK)", "mpg_us": "mpg (US)" } @@ -95,7 +95,7 @@ } }, "average_energy_consumption": { - "name": "TM Avg. energy consumption", + "name": "TM avg. energy consumption", "state_attributes": { "api_timestamp": { "name": "[%key:component::volvo::common::api_timestamp%]" @@ -103,7 +103,7 @@ } }, "average_energy_consumption_automatic": { - "name": "TA Avg. energy consumption", + "name": "TA avg. energy consumption", "state_attributes": { "api_timestamp": { "name": "[%key:component::volvo::common::api_timestamp%]" @@ -119,7 +119,7 @@ } }, "average_fuel_consumption": { - "name": "TM Avg. fuel consumption", + "name": "TM avg. fuel consumption", "state_attributes": { "api_timestamp": { "name": "[%key:component::volvo::common::api_timestamp%]" @@ -127,7 +127,7 @@ } }, "average_fuel_consumption_automatic": { - "name": "TA Avg. fuel consumption", + "name": "TA avg. fuel consumption", "state_attributes": { "api_timestamp": { "name": "[%key:component::volvo::common::api_timestamp%]" @@ -135,7 +135,7 @@ } }, "average_speed": { - "name": "TM Avg. speed", + "name": "TM avg. speed", "state_attributes": { "api_timestamp": { "name": "[%key:component::volvo::common::api_timestamp%]" @@ -143,7 +143,7 @@ } }, "average_speed_automatic": { - "name": "TA Avg. speed", + "name": "TA avg. speed", "state_attributes": { "api_timestamp": { "name": "[%key:component::volvo::common::api_timestamp%]" @@ -179,7 +179,7 @@ "state": { "connection_status_connected_ac": "Connected AC", "connection_status_connected_dc": "Connected DC", - "connection_status_disconnected": "Disconnected", + "connection_status_disconnected": "[%key:common::state::disconnected%]", "connection_status_fault": "[%key:component::volvo::common::error%]", "connection_status_unspecified": "[%key:component::volvo::common::unknown%]" }, @@ -192,10 +192,10 @@ "charging_system_status": { "name": "Charging status", "state": { - "charging_system_charging": "Charging", + "charging_system_charging": "[%key:common::state::charging%]", "charging_system_done": "Done", "charging_system_fault": "[%key:component::volvo::common::error%]", - "charging_system_idle": "Idle", + "charging_system_idle": "[%key:common::state::idle%]", "charging_system_scheduled": "Scheduled", "charging_system_unspecified": "[%key:component::volvo::common::unknown%]" }, @@ -278,7 +278,7 @@ } }, "trip_meter_automatic": { - "name": "TA Distance", + "name": "TA distance", "state_attributes": { "api_timestamp": { "name": "[%key:component::volvo::common::api_timestamp%]" @@ -286,7 +286,7 @@ } }, "trip_meter_manual": { - "name": "TM Distance", + "name": "TM distance", "state_attributes": { "api_timestamp": { "name": "[%key:component::volvo::common::api_timestamp%]" From 8fb7ea7bfa8f0bf502655f73929d520dc865cdfe Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 19 Apr 2025 14:04:38 +0000 Subject: [PATCH 07/12] Remove fuel consumption options --- homeassistant/components/volvo/__init__.py | 8 -- homeassistant/components/volvo/config_flow.py | 83 +------------------ homeassistant/components/volvo/const.py | 5 -- homeassistant/components/volvo/sensor.py | 58 +------------ homeassistant/components/volvo/strings.json | 24 ------ tests/components/volvo/test_config_flow.py | 74 +---------------- tests/components/volvo/test_sensor.py | 56 ------------- 7 files changed, 8 insertions(+), 300 deletions(-) diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index 2ba63d7c25c75e..d0e8e98951e968 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -54,9 +54,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo entry.runtime_data = VolvoData(coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Register events - entry.async_on_unload(entry.add_update_listener(_entry_update_listener)) - return True @@ -64,8 +61,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bo """Unload a config entry.""" _LOGGER.debug("%s - Unloading entry", entry.entry_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _entry_update_listener(hass: HomeAssistant, entry: VolvoConfigEntry) -> None: - """Reload entry after config changes.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py index 0e38da1f161900..0593cac2dfc511 100644 --- a/homeassistant/components/volvo/config_flow.py +++ b/homeassistant/components/volvo/config_flow.py @@ -4,7 +4,7 @@ from collections.abc import Mapping import logging -from typing import TYPE_CHECKING, Any +from typing import Any import voluptuous as vol from volvocarsapi.api import VolvoCarsApi @@ -14,40 +14,18 @@ SOURCE_REAUTH, SOURCE_RECONFIGURE, ConfigFlowResult, - OptionsFlow, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_NAME, CONF_TOKEN -from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig -from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .api import ConfigFlowVolvoAuth -from .const import ( - CONF_VIN, - DOMAIN, - MANUFACTURER, - OPT_FUEL_CONSUMPTION_UNIT, - OPT_FUEL_UNIT_LITER_PER_100KM, - OPT_FUEL_UNIT_MPG_UK, - OPT_FUEL_UNIT_MPG_US, -) -from .coordinator import VolvoConfigEntry, VolvoData +from .const import CONF_VIN, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) -def _default_fuel_unit(hass: HomeAssistant) -> str: - if hass.config.country == "UK": - return OPT_FUEL_UNIT_MPG_UK - - if hass.config.units == US_CUSTOMARY_SYSTEM or hass.config.country == "US": - return OPT_FUEL_UNIT_MPG_US - - return OPT_FUEL_UNIT_LITER_PER_100KM - - class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle Volvo OAuth2 authentication.""" @@ -83,13 +61,6 @@ async def async_step_reconfigure( """Reconfigure the entry.""" return await self.async_step_api_key() - # Overridden method - @staticmethod - @callback - def async_get_options_flow(_: VolvoConfigEntry) -> VolvoOptionsFlowHandler: - """Create the options flow.""" - return VolvoOptionsFlowHandler() - async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -202,54 +173,4 @@ async def _async_create_or_update(self) -> ConfigFlowResult: return self.async_create_entry( title=f"{MANUFACTURER} {vin}", data=self._config_data, - options={OPT_FUEL_CONSUMPTION_UNIT: _default_fuel_unit(self.hass)}, - ) - - -class VolvoOptionsFlowHandler(OptionsFlow): - """Class to handle the options.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry(data=user_input) - - if TYPE_CHECKING: - assert isinstance(self.config_entry.runtime_data, VolvoData) - - coordinator = self.config_entry.runtime_data.coordinator - schema: dict[vol.Marker, Any] = {} - - if coordinator.vehicle.has_combustion_engine(): - schema.update( - { - vol.Required( - OPT_FUEL_CONSUMPTION_UNIT, - default=self.config_entry.options.get( - OPT_FUEL_CONSUMPTION_UNIT, OPT_FUEL_UNIT_LITER_PER_100KM - ), - ): SelectSelector( - SelectSelectorConfig( - options=[ - OPT_FUEL_UNIT_LITER_PER_100KM, - OPT_FUEL_UNIT_MPG_UK, - OPT_FUEL_UNIT_MPG_US, - ], - multiple=False, - translation_key=OPT_FUEL_CONSUMPTION_UNIT, - ) - ) - } - ) - - if len(schema) == 0: - return self.async_abort(reason="no_options_available") - - return self.async_show_form( - step_id="init", - data_schema=self.add_suggested_values_to_schema( - vol.Schema(schema), self.config_entry.options - ), ) diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py index de52344b34165b..fa846e880d7db3 100644 --- a/homeassistant/components/volvo/const.py +++ b/homeassistant/components/volvo/const.py @@ -13,11 +13,6 @@ MANUFACTURER = "Volvo" -OPT_FUEL_CONSUMPTION_UNIT = "fuel_consumption_unit" -OPT_FUEL_UNIT_LITER_PER_100KM = "l_100km" -OPT_FUEL_UNIT_MPG_UK = "mpg_uk" -OPT_FUEL_UNIT_MPG_US = "mpg_us" - SCOPES = [ "openid", "conve:battery_charge_level", diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index 1b69ae6822815a..fc6b448f57474e 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -4,7 +4,6 @@ from collections.abc import Callable from dataclasses import dataclass -from decimal import Decimal from typing import Any from volvocarsapi.models import ( @@ -34,13 +33,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - DATA_BATTERY_CAPACITY, - OPT_FUEL_CONSUMPTION_UNIT, - OPT_FUEL_UNIT_LITER_PER_100KM, - OPT_FUEL_UNIT_MPG_UK, - OPT_FUEL_UNIT_MPG_US, -) +from .const import DATA_BATTERY_CAPACITY from .coordinator import VolvoConfigEntry, VolvoDataCoordinator from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key @@ -52,7 +45,6 @@ class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): """Describes a Volvo sensor entity.""" value_fn: Callable[[VolvoCarsValue, VolvoConfigEntry], Any] | None = None - unit_fn: Callable[[VolvoConfigEntry], str] | None = None available_fn: Callable[[VolvoCarsVehicle], bool] = lambda vehicle: True @@ -71,41 +63,6 @@ def _calculate_time_to_service(field: VolvoCarsValue, _: VolvoConfigEntry) -> in return value -def _determine_fuel_consumption_unit(entry: VolvoConfigEntry) -> str: - unit_key = entry.options.get( - OPT_FUEL_CONSUMPTION_UNIT, OPT_FUEL_UNIT_LITER_PER_100KM - ) - - return ( - "mpg" - if unit_key in (OPT_FUEL_UNIT_MPG_UK, OPT_FUEL_UNIT_MPG_US) - else "L/100 km" - ) - - -def _convert_fuel_consumption( - field: VolvoCarsValue, entry: VolvoConfigEntry -) -> Decimal: - value = Decimal(field.value) - - decimals = 1 - converted_value = value - - unit_key = entry.options.get( - OPT_FUEL_CONSUMPTION_UNIT, OPT_FUEL_UNIT_LITER_PER_100KM - ) - - if unit_key == OPT_FUEL_UNIT_MPG_UK: - decimals = 2 - converted_value = (Decimal("282.481") / value) if value else Decimal(0) - - elif unit_key == OPT_FUEL_UNIT_MPG_US: - decimals = 2 - converted_value = (Decimal("235.215") / value) if value else Decimal(0) - - return round(converted_value, decimals) - - _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( # command-accessibility endpoint VolvoSensorDescription( @@ -154,22 +111,18 @@ def _convert_fuel_consumption( key="average_fuel_consumption", translation_key="average_fuel_consumption", api_field="averageFuelConsumption", - native_unit_of_measurement="L/100 km", + native_unit_of_measurement="l/100 km", state_class=SensorStateClass.MEASUREMENT, available_fn=lambda vehicle: vehicle.has_combustion_engine(), - unit_fn=_determine_fuel_consumption_unit, - value_fn=_convert_fuel_consumption, ), # statistics endpoint VolvoSensorDescription( key="average_fuel_consumption_automatic", translation_key="average_fuel_consumption_automatic", api_field="averageFuelConsumptionAutomatic", - native_unit_of_measurement="L/100 km", + native_unit_of_measurement="l/100 km", state_class=SensorStateClass.MEASUREMENT, available_fn=lambda vehicle: vehicle.has_combustion_engine(), - unit_fn=_determine_fuel_consumption_unit, - value_fn=_convert_fuel_consumption, ), # statistics endpoint VolvoSensorDescription( @@ -383,11 +336,6 @@ def __init__( """Initialize.""" super().__init__(coordinator, description) - if description.unit_fn: - self._attr_native_unit_of_measurement = description.unit_fn( - self.coordinator.config_entry - ) - def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: assert isinstance(api_field, VolvoCarsValue) diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 6cd2b051bfa534..98d83ae8f86af8 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -52,30 +52,6 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, - "options": { - "abort": { - "no_options_available": "No options available for your vehicle." - }, - "step": { - "init": { - "data": { - "fuel_consumption_unit": "Fuel consumption unit" - }, - "data_description": { - "fuel_consumption_unit": "Choose the preferred unit" - } - } - } - }, - "selector": { - "fuel_consumption_unit": { - "options": { - "l_100km": "l/100 km", - "mpg_uk": "mpg (UK)", - "mpg_us": "mpg (US)" - } - } - }, "entity": { "sensor": { "availability": { diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py index 83253848419d39..e787bc8b7f54c6 100644 --- a/tests/components/volvo/test_config_flow.py +++ b/tests/components/volvo/test_config_flow.py @@ -3,67 +3,37 @@ from unittest.mock import AsyncMock import pytest -from volvocarsapi.api import _API_CONNECTED_ENDPOINT, _API_URL, VolvoCarsApi +from volvocarsapi.api import _API_CONNECTED_ENDPOINT, _API_URL from volvocarsapi.auth import TOKEN_URL from volvocarsapi.models import VolvoApiException -from homeassistant.components.volvo.const import ( - CONF_VIN, - DOMAIN, - OPT_FUEL_CONSUMPTION_UNIT, - OPT_FUEL_UNIT_LITER_PER_100KM, - OPT_FUEL_UNIT_MPG_UK, - OPT_FUEL_UNIT_MPG_US, -) -from homeassistant.components.volvo.coordinator import VolvoData +from homeassistant.components.volvo.const import CONF_VIN, DOMAIN from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem -from .conftest import model from .const import REDIRECT_URI, SERVER_TOKEN_RESPONSE -from tests.common import METRIC_SYSTEM, MockConfigEntry +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -@pytest.mark.parametrize( - ("country", "units", "expected_fuel_unit"), - [ - ("BE", METRIC_SYSTEM, OPT_FUEL_UNIT_LITER_PER_100KM), - ("NL", METRIC_SYSTEM, OPT_FUEL_UNIT_LITER_PER_100KM), - ("BE", US_CUSTOMARY_SYSTEM, OPT_FUEL_UNIT_MPG_US), - ("UK", METRIC_SYSTEM, OPT_FUEL_UNIT_MPG_UK), - ("UK", US_CUSTOMARY_SYSTEM, OPT_FUEL_UNIT_MPG_UK), - ("US", US_CUSTOMARY_SYSTEM, OPT_FUEL_UNIT_MPG_US), - ("US", METRIC_SYSTEM, OPT_FUEL_UNIT_MPG_US), - ], -) @pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, config_flow: ConfigFlowResult, mock_setup_entry: AsyncMock, aioclient_mock: AiohttpClientMocker, - *, - country: str, - units: UnitSystem, - expected_fuel_unit: str, ) -> None: """Check full flow.""" - hass.config.country = country - hass.config.units = units - config_flow = await _async_run_flow_to_completion(hass, config_flow, aioclient_mock) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert config_flow["type"] is FlowResultType.CREATE_ENTRY - assert config_flow["options"][OPT_FUEL_CONSUMPTION_UNIT] == expected_fuel_unit @pytest.mark.usefixtures("current_request_with_host") @@ -202,44 +172,6 @@ async def test_api_failure_flow( assert config_flow["step_id"] == "api_key" -@pytest.mark.parametrize( - ("fuel_unit"), - [ - (OPT_FUEL_UNIT_LITER_PER_100KM), - (OPT_FUEL_UNIT_MPG_UK), - (OPT_FUEL_UNIT_MPG_US), - ], -) -async def test_options_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, fuel_unit: str -) -> None: - """Test options flow.""" - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={OPT_FUEL_CONSUMPTION_UNIT: fuel_unit} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert mock_config_entry.options[OPT_FUEL_CONSUMPTION_UNIT] == fuel_unit - - -@model("xc40_electric_2024") -async def test_no_options_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: AsyncMock -) -> None: - """Test options flow where no options are available.""" - volvo_data: VolvoData = mock_config_entry.runtime_data - api: VolvoCarsApi = mock_api.return_value - - volvo_data.coordinator.vehicle = await api.async_get_vehicle_details() - - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_options_available" - - async def _async_run_flow_to_completion( hass: HomeAssistant, config_flow: ConfigFlowResult, diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index 54406718d39bab..a803a98c97c82e 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -5,67 +5,11 @@ import pytest -from homeassistant.components.volvo.const import ( - OPT_FUEL_CONSUMPTION_UNIT, - OPT_FUEL_UNIT_LITER_PER_100KM, - OPT_FUEL_UNIT_MPG_UK, - OPT_FUEL_UNIT_MPG_US, -) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .conftest import model -from tests.common import MockConfigEntry - - -@pytest.mark.parametrize( - ("unit", "value", "unit_of_measurement"), - [ - ( - OPT_FUEL_UNIT_LITER_PER_100KM, - "7.2", - "L/100 km", - ), - ( - OPT_FUEL_UNIT_MPG_UK, - "39.07", - "mpg", - ), - ( - OPT_FUEL_UNIT_MPG_US, - "32.53", - "mpg", - ), - ], -) -@model("xc90_petrol_2019") -async def test_fuel_unit_conversion( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - setup_integration: Callable[[], Awaitable[bool]], - unit: str, - value: str, - unit_of_measurement: str, -) -> None: - """Test fuel unit conversion.""" - - entity_id = "sensor.volvo_xc90_petrol_2019_tm_avg_fuel_consumption" - - with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): - assert await setup_integration() - - hass.config_entries.async_update_entry( - mock_config_entry, - options={OPT_FUEL_CONSUMPTION_UNIT: unit}, - ) - await hass.async_block_till_done() - - entity = hass.states.get(entity_id) - assert entity - assert entity.state == value - assert entity.attributes.get("unit_of_measurement") == unit_of_measurement - @pytest.mark.parametrize( ("expected_state"), From 19021b157a9d8ba55fe7b2d3e90b56b37eb2da71 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 19 Apr 2025 14:14:45 +0000 Subject: [PATCH 08/12] Simplify coordinator data initialization --- homeassistant/components/volvo/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index a61f31a0b18854..a5f6bf28668f97 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -94,7 +94,7 @@ async def _async_setup(self) -> None: ) self.vehicle = vehicle - self.data = self.data or {} + self.data = {} device_name = ( f"{MANUFACTURER} {vehicle.description.model} {vehicle.model_year}" From f1c919bc086b799c2ffc542db7d1769523838426 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 19 Apr 2025 14:25:05 +0000 Subject: [PATCH 09/12] Update requirement --- homeassistant/components/volvo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json index a4676f9483f462..269f96e81b1cb9 100644 --- a/homeassistant/components/volvo/manifest.json +++ b/homeassistant/components/volvo/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["volvocarsapi"], "quality_scale": "silver", - "requirements": ["volvocarsapi==0.0.2b0"] + "requirements": ["volvocarsapi==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2bf0341b310884..877eb0170c4351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3027,7 +3027,7 @@ voip-utils==0.3.1 volkszaehler==0.4.0 # homeassistant.components.volvo -volvocarsapi==0.0.2b0 +volvocarsapi==0.1.0 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10fd1f143bb844..1bac007ea7d4e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2444,7 +2444,7 @@ vilfo-api-client==0.5.0 voip-utils==0.3.1 # homeassistant.components.volvo -volvocarsapi==0.0.2b0 +volvocarsapi==0.1.0 # homeassistant.components.volvooncall volvooncall==0.10.3 From b6bd17f7c135e68bb35d1c04877daf1aafc7280d Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 19 Apr 2025 15:17:34 +0000 Subject: [PATCH 10/12] Update sensor tests --- .../fixtures/s90_diesel_2018/statistics.json | 32 ++ .../volvo/snapshots/test_sensor.ambr | 292 ++++++++++++++++++ tests/components/volvo/test_sensor.py | 61 +++- 3 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/statistics.json create mode 100644 tests/components/volvo/snapshots/test_sensor.ambr diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json b/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json new file mode 100644 index 00000000000000..9f6760451edbf2 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 7.23, + "unit": "l/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyTank": { + "value": 147, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..b17cf299786a69 --- /dev/null +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -0,0 +1,292 @@ +# serializer version: 1 +# name: test_sensor[keys0] + tuple( + '58.0', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 30, 8, tzinfo=datetime.timezone.utc), + 'device_class': 'battery', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Battery charge level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'volvo_yv1abcdefg1234567_battery_charge_level', + ) +# --- +# name: test_sensor[keys0].1 + tuple( + 'available', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 32, 26, 169000, tzinfo=datetime.timezone.utc), + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'power_saving_mode', + 'unspecified', + ]), + }), + 'volvo_yv1abcdefg1234567_availability', + ) +# --- +# name: test_sensor[keys0].10 + tuple( + '53', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'device_class': 'speed', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 TM avg. speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_average_speed', + ) +# --- +# name: test_sensor[keys0].11 + tuple( + '3822.9', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 TM distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_trip_meter_manual', + ) +# --- +# name: test_sensor[keys0].2 + tuple( + 'connection_status_connected_ac', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 30, 8, tzinfo=datetime.timezone.utc), + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Charging connection status', + 'options': list([ + 'connection_status_connected_ac', + 'connection_status_connected_dc', + 'connection_status_disconnected', + 'connection_status_fault', + 'connection_status_unspecified', + ]), + }), + 'volvo_yv1abcdefg1234567_charging_connection_status', + ) +# --- +# name: test_sensor[keys0].3 + tuple( + '250', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 30, 8, 338000, tzinfo=datetime.timezone.utc), + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_distance_to_empty_battery', + ) +# --- +# name: test_sensor[keys0].4 + tuple( + '29000', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_distance_to_service', + ) +# --- +# name: test_sensor[keys0].5 + tuple( + '1266', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Engine time to service', + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_engine_time_to_service', + ) +# --- +# name: test_sensor[keys0].6 + tuple( + '780', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 30, 8, tzinfo=datetime.timezone.utc), + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Est. charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_estimated_charging_time', + ) +# --- +# name: test_sensor[keys0].7 + tuple( + '30000', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_odometer', + ) +# --- +# name: test_sensor[keys0].8 + tuple( + '690', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 Time to service', + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_time_to_service', + ) +# --- +# name: test_sensor[keys0].9 + tuple( + '22.6', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 53, 44, 785000, tzinfo=datetime.timezone.utc), + 'friendly_name': 'Volvo XC40 ELECTRIC 2024 TM avg. energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_average_energy_consumption', + ) +# --- +# name: test_sensor[keys1] + tuple( + 'available', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 32, 26, 169000, tzinfo=datetime.timezone.utc), + 'device_class': 'enum', + 'friendly_name': 'Volvo S90 DIESEL 2018 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'power_saving_mode', + 'unspecified', + ]), + }), + 'volvo_yv1abcdefg1234567_availability', + ) +# --- +# name: test_sensor[keys1].1 + tuple( + '147', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 30, 8, 338000, tzinfo=datetime.timezone.utc), + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 DIESEL 2018 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_distance_to_empty_tank', + ) +# --- +# name: test_sensor[keys1].2 + tuple( + '29000', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 DIESEL 2018 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_distance_to_service', + ) +# --- +# name: test_sensor[keys1].3 + tuple( + '1266', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'friendly_name': 'Volvo S90 DIESEL 2018 Engine time to service', + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_engine_time_to_service', + ) +# --- +# name: test_sensor[keys1].4 + tuple( + '47.3', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2020, 11, 19, 21, 23, 24, 123000, tzinfo=datetime.timezone.utc), + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo S90 DIESEL 2018 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_fuel_amount', + ) +# --- +# name: test_sensor[keys1].5 + tuple( + '30000', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 DIESEL 2018 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_odometer', + ) +# --- +# name: test_sensor[keys1].6 + tuple( + '17', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'friendly_name': 'Volvo S90 DIESEL 2018 Time to service', + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_time_to_service', + ) +# --- +# name: test_sensor[keys1].7 + tuple( + '7.23', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 53, 44, 785000, tzinfo=datetime.timezone.utc), + 'friendly_name': 'Volvo S90 DIESEL 2018 TM avg. fuel consumption', + 'state_class': , + 'unit_of_measurement': 'l/100 km', + }), + 'volvo_yv1abcdefg1234567_average_fuel_consumption', + ) +# --- +# name: test_sensor[keys1].8 + tuple( + '53', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'device_class': 'speed', + 'friendly_name': 'Volvo S90 DIESEL 2018 TM avg. speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_average_speed', + ) +# --- +# name: test_sensor[keys1].9 + tuple( + '3822.9', + ReadOnlyDict({ + 'api_timestamp': datetime.datetime(2024, 12, 30, 14, 18, 56, 849000, tzinfo=datetime.timezone.utc), + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 DIESEL 2018 TM distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'volvo_yv1abcdefg1234567_trip_meter_manual', + ) +# --- diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index a803a98c97c82e..77a420f6dd1212 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -4,33 +4,74 @@ from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import model @pytest.mark.parametrize( - ("expected_state"), + ("keys"), [ - pytest.param(23 * 30, marks=model("xc40_electric_2024")), - pytest.param(17, marks=model("s90_diesel_2018")), + pytest.param( + [ + "battery_charge_level", + "car_connection", + "charging_connection_status", + "distance_to_empty_battery", + "distance_to_service", + "engine_time_to_service", + "est_charging_time", + "odometer", + "time_to_service", + "tm_avg_energy_consumption", + "tm_avg_speed", + "tm_distance", + ], + marks=model("xc40_electric_2024"), + ), + pytest.param( + [ + "car_connection", + "distance_to_empty_tank", + "distance_to_service", + "engine_time_to_service", + "fuel_amount", + "odometer", + "time_to_service", + "tm_avg_fuel_consumption", + "tm_avg_speed", + "tm_distance", + ], + marks=model("s90_diesel_2018"), + ), ], ) -async def test_time_to_service( +async def test_sensor( hass: HomeAssistant, setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, model_from_marker: str, - expected_state: int, + keys: list[str], + snapshot: SnapshotAssertion, ) -> None: """Test time to service.""" - entity_id = f"sensor.volvo_{model_from_marker}_time_to_service" - with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): assert await setup_integration() - entity = hass.states.get(entity_id) - assert entity - assert entity.state == f"{expected_state}" + for key in keys: + entity_id = f"sensor.volvo_{model_from_marker}_{key}" + + state = hass.states.get(entity_id) + assert state, f"No state found for {entity_id}" + + entry = entity_registry.async_get(entity_id) + assert entry, f"No entry found for {entity_id}" + + assert (state.state, state.attributes, entry.unique_id) == snapshot, ( + f"Snapshot does not match for {entity_id}" + ) From db496af4d374a837acda8bab4130e9a58539a6b8 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Mon, 21 Apr 2025 14:02:16 +0000 Subject: [PATCH 11/12] Use common translation values --- homeassistant/components/volvo/sensor.py | 14 +++++++++++++- homeassistant/components/volvo/strings.json | 15 +++++---------- tests/components/volvo/test_sensor.py | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index fc6b448f57474e..c02e328a2407a4 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -39,6 +39,13 @@ PARALLEL_UPDATES = 0 +# Entities having an "unknown" value should report None as the state +_UNKNOWN_VALUES = [ + "UNSPECIFIED", + "CONNECTION_STATUS_UNSPECIFIED", + "CHARGING_SYSTEM_UNSPECIFIED", +] + @dataclass(frozen=True, kw_only=True) class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): @@ -348,6 +355,11 @@ def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: ) if self.device_class == SensorDeviceClass.ENUM: - native_value = value_to_translation_key(str(native_value)) + native_value = str(native_value) + native_value = ( + value_to_translation_key(native_value) + if native_value.upper() not in _UNKNOWN_VALUES + else None + ) self._attr_native_value = native_value diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 98d83ae8f86af8..ffd30a42eb105d 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -1,8 +1,6 @@ { "common": { - "api_timestamp": "API timestamp", - "error": "Error", - "unknown": "Unknown" + "api_timestamp": "API timestamp" }, "config": { "step": { @@ -61,8 +59,7 @@ "car_in_use": "Car is in use", "no_internet": "No internet", "power_saving_mode": "Power saving mode", - "unavailable": "Unavailable", - "unspecified": "[%key:component::volvo::common::unknown%]" + "unavailable": "Unavailable" }, "state_attributes": { "api_timestamp": { @@ -156,8 +153,7 @@ "connection_status_connected_ac": "Connected AC", "connection_status_connected_dc": "Connected DC", "connection_status_disconnected": "[%key:common::state::disconnected%]", - "connection_status_fault": "[%key:component::volvo::common::error%]", - "connection_status_unspecified": "[%key:component::volvo::common::unknown%]" + "connection_status_fault": "[%key:common::state::error%]" }, "state_attributes": { "api_timestamp": { @@ -170,10 +166,9 @@ "state": { "charging_system_charging": "[%key:common::state::charging%]", "charging_system_done": "Done", - "charging_system_fault": "[%key:component::volvo::common::error%]", + "charging_system_fault": "[%key:common::state::error%]", "charging_system_idle": "[%key:common::state::idle%]", - "charging_system_scheduled": "Scheduled", - "charging_system_unspecified": "[%key:component::volvo::common::unknown%]" + "charging_system_scheduled": "Scheduled" }, "state_attributes": { "api_timestamp": { diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index 77a420f6dd1212..a892db4549322e 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -58,7 +58,7 @@ async def test_sensor( keys: list[str], snapshot: SnapshotAssertion, ) -> None: - """Test time to service.""" + """Test sensor.""" with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): assert await setup_integration() From 3d4ed5b2a070d3714fbb785da1ea667189c987a0 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Wed, 23 Apr 2025 13:28:44 +0000 Subject: [PATCH 12/12] Bump requirement --- homeassistant/components/volvo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json index 269f96e81b1cb9..e35397f13f6dfa 100644 --- a/homeassistant/components/volvo/manifest.json +++ b/homeassistant/components/volvo/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["volvocarsapi"], "quality_scale": "silver", - "requirements": ["volvocarsapi==0.1.0"] + "requirements": ["volvocarsapi==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 877eb0170c4351..bcb25b41627736 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3027,7 +3027,7 @@ voip-utils==0.3.1 volkszaehler==0.4.0 # homeassistant.components.volvo -volvocarsapi==0.1.0 +volvocarsapi==0.1.1 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bac007ea7d4e9..57f178e1e32070 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2444,7 +2444,7 @@ vilfo-api-client==0.5.0 voip-utils==0.3.1 # homeassistant.components.volvo -volvocarsapi==0.1.0 +volvocarsapi==0.1.1 # homeassistant.components.volvooncall volvooncall==0.10.3