diff --git a/.strict-typing b/.strict-typing index be6f540e633a14..48addc485876c7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -536,6 +536,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 5896972959e2f9..0f0af812852217 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1679,6 +1679,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..d0e8e98951e968 --- /dev/null +++ b/homeassistant/components/volvo/__init__.py @@ -0,0 +1,63 @@ +"""The Volvo integration.""" + +from __future__ import annotations + +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.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 +from .coordinator import VolvoConfigEntry, VolvoData, VolvoDataCoordinator + +_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 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: + 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, + entry.data[CONF_VIN], + entry.data[CONF_API_KEY], + ) + + # Setup entry + coordinator = VolvoDataCoordinator(hass, entry, api) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = VolvoData(coordinator) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + 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) diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py new file mode 100644 index 00000000000000..e2c1070f1ea528 --- /dev/null +++ b/homeassistant/components/volvo/api.py @@ -0,0 +1,38 @@ +"""API for Volvo bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession +from volvocarsapi.auth import AccessTokenManager + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + + +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..24b7e44566e676 --- /dev/null +++ b/homeassistant/components/volvo/application_credentials.py @@ -0,0 +1,58 @@ +"""Application credentials platform for the Volvo integration.""" + +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL + +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 + + +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..0593cac2dfc511 --- /dev/null +++ b/homeassistant/components/volvo/config_flow.py @@ -0,0 +1,176 @@ +"""Config flow for Volvo.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoApiException + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_NAME, CONF_TOKEN +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from .api import ConfigFlowVolvoAuth +from .const import CONF_VIN, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +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() + + 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, + ) diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py new file mode 100644 index 00000000000000..fa846e880d7db3 --- /dev/null +++ b/homeassistant/components/volvo/const.py @@ -0,0 +1,43 @@ +"""Constants for the Volvo integration.""" + +from homeassistant.const import Platform + +DOMAIN = "volvo" +PLATFORMS: list[Platform] = [Platform.SENSOR] + +ATTR_API_TIMESTAMP = "api_timestamp" + +CONF_VIN = "vin" + +DATA_BATTERY_CAPACITY = "battery_capacity_kwh" + +MANUFACTURER = "Volvo" + +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_status", + "conve:fuel_status", + "conve:lock_status", + "conve:odometer_status", + "conve:trip_statistics", + "conve:tyre_status", + "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", +] diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py new file mode 100644 index 00000000000000..a5f6bf28668f97 --- /dev/null +++ b/homeassistant/components/volvo/coordinator.py @@ -0,0 +1,216 @@ +"""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 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 +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_BATTERY_CAPACITY, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class VolvoData: + """Data for Volvo Cars integration.""" + + coordinator: VolvoDataCoordinator + + +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: VolvoCarsApi = 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() + except VolvoAuthException as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="unauthorized", + 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 = {} + + 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..fc35f4331e1227 --- /dev/null +++ b/homeassistant/components/volvo/entity.py @@ -0,0 +1,73 @@ +"""Volvo entity classes.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +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 ATTR_API_TIMESTAMP, CONF_VIN, DOMAIN +from .coordinator import VolvoDataCoordinator + + +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(ABC, 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 + self._attr_extra_state_attributes = {} + + 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 + + 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: + raise NotImplementedError 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..e35397f13f6dfa --- /dev/null +++ b/homeassistant/components/volvo/manifest.json @@ -0,0 +1,13 @@ +{ + "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", + "loggers": ["volvocarsapi"], + "quality_scale": "silver", + "requirements": ["volvocarsapi==0.1.1"] +} diff --git a/homeassistant/components/volvo/quality_scale.yaml b/homeassistant/components/volvo/quality_scale.yaml new file mode 100644 index 00000000000000..544b64e964d503 --- /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: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No discovery possible. + discovery: + status: exempt + comment: | + No discovery possible. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + 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..c02e328a2407a4 --- /dev/null +++ b/homeassistant/components/volvo/sensor.py @@ -0,0 +1,365 @@ +"""Volvo sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from volvocarsapi.models import ( + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsValueField, + VolvoCarsVehicle, +) + +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 +from .coordinator import VolvoConfigEntry, VolvoDataCoordinator +from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key + +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): + """Describes a Volvo sensor entity.""" + + value_fn: Callable[[VolvoCarsValue, VolvoConfigEntry], Any] | 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 + + +_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(), + ), + # 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(), + ), + # 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) + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + assert isinstance(api_field, VolvoCarsValue) + + 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 = 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 new file mode 100644 index 00000000000000..ffd30a42eb105d --- /dev/null +++ b/homeassistant/components/volvo/strings.json @@ -0,0 +1,280 @@ +{ + "common": { + "api_timestamp": "API timestamp" + }, + "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": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The Volvo developers API key" + } + }, + "vin": { + "data": { + "vin": "VIN" + }, + "data_description": { + "vin": "The Vehicle Identification Number 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%]" + } + }, + "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" + }, + "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": "[%key:common::state::disconnected%]", + "connection_status_fault": "[%key:common::state::error%]" + }, + "state_attributes": { + "api_timestamp": { + "name": "[%key:component::volvo::common::api_timestamp%]" + } + } + }, + "charging_system_status": { + "name": "Charging status", + "state": { + "charging_system_charging": "[%key:common::state::charging%]", + "charging_system_done": "Done", + "charging_system_fault": "[%key:common::state::error%]", + "charging_system_idle": "[%key:common::state::idle%]", + "charging_system_scheduled": "Scheduled" + }, + "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 2f088716f8c4bd..0abd4365febd0c 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -35,6 +35,7 @@ "spotify", "tesla_fleet", "twitch", + "volvo", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f6c658b396a32b..8707d34143a0f7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -688,6 +688,7 @@ "vodafone_station", "voip", "volumio", + "volvo", "volvooncall", "vulcan", "wake_on_lan", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 63e97c96585fa5..79550bc207aa67 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7165,6 +7165,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 5c6db87590f5c4..84563274d7c918 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5119,6 +5119,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/requirements_all.txt b/requirements_all.txt index 8466a231922914..e83beb9547e629 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3029,6 +3029,9 @@ voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 +# homeassistant.components.volvo +volvocarsapi==0.1.1 + # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 964ccad34a6d42..5191460216a782 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2446,6 +2446,9 @@ vilfo-api-client==0.5.0 # homeassistant.components.voip voip-utils==0.3.1 +# homeassistant.components.volvo +volvocarsapi==0.1.1 + # homeassistant.components.volvooncall volvooncall==0.10.3 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/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 new file mode 100644 index 00000000000000..c483c1e98b12f5 --- /dev/null +++ b/tests/components/volvo/conftest.py @@ -0,0 +1,251 @@ +"""Define fixtures for Volvo unit tests.""" + +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, TOKEN_URL +from volvocarsapi.models import ( + VolvoApiException, + VolvoCarsAvailableCommand, + VolvoCarsLocation, + VolvoCarsValueField, + VolvoCarsVehicle, +) +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.coordinator import VolvoData +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, + 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 + + +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: { + "access_token": MOCK_ACCESS_TOKEN, + "refresh_token": "mock-refresh-token", + "expires_at": 123456789, + }, + }, + ) + + 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[AsyncMock]: + """Mock the Volvo API.""" + model = model_from_marker + + 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] + + 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) + + yield mock_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) +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, +) -> 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 +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.""" + with patch( + "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 new file mode 100644 index 00000000000000..9ccb38ca913e15 --- /dev/null +++ b/tests/components/volvo/const.py @@ -0,0 +1,15 @@ +"""Define const for Volvo unit tests.""" + +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/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/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/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/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_config_flow.py b/tests/components/volvo/test_config_flow.py new file mode 100644 index 00000000000000..e787bc8b7f54c6 --- /dev/null +++ b/tests/components/volvo/test_config_flow.py @@ -0,0 +1,210 @@ +"""Test the Volvo config flow.""" + +from unittest.mock import AsyncMock + +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, 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 .const import REDIRECT_URI, SERVER_TOKEN_RESPONSE + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" + 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 + + +@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=SERVER_TOKEN_RESPONSE, + ) + + aioclient_mock.get( + f"{_API_URL}{_API_CONNECTED_ENDPOINT}", + json={ + "data": [{"vin": "YV1ABCDEFG1234567"}], + }, + ) + + # 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") +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": "YV1ABCDEFG1234567"}, {"vin": "YV10000000AAAAAAA"}], + }, + ) + + 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=SERVER_TOKEN_RESPONSE, + ) + + 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" + + +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=SERVER_TOKEN_RESPONSE, + ) + + aioclient_mock.get( + f"{_API_URL}{_API_CONNECTED_ENDPOINT}", + json={ + "data": [{"vin": "YV1ABCDEFG1234567"}, {"vin": "YV10000000AAAAAAA"}], + }, + ) + + 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: "YV1ABCDEFG1234567"} + ) + + return config_flow 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 new file mode 100644 index 00000000000000..a892db4549322e --- /dev/null +++ b/tests/components/volvo/test_sensor.py @@ -0,0 +1,77 @@ +"""Test Volvo sensors.""" + +from collections.abc import Awaitable, Callable +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( + ("keys"), + [ + 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_sensor( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + model_from_marker: str, + keys: list[str], + snapshot: SnapshotAssertion, +) -> None: + """Test sensor.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + 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}" + )