mirror of
https://github.com/home-assistant/core.git
synced 2025-07-29 08:07:45 +00:00
Add new Volvo integration (#142994)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
parent
8f795f021c
commit
483d814a8f
@ -547,6 +547,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.*
|
||||
|
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1706,6 +1706,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/voip/ @balloob @synesthesiam @jaminh
|
||||
/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
|
||||
|
97
homeassistant/components/volvo/__init__.py
Normal file
97
homeassistant/components/volvo/__init__.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""The Volvo integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
from volvocarsapi.models import VolvoAuthException, VolvoCarsVehicle
|
||||
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
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, DOMAIN, PLATFORMS
|
||||
from .coordinator import (
|
||||
VolvoConfigEntry,
|
||||
VolvoMediumIntervalCoordinator,
|
||||
VolvoSlowIntervalCoordinator,
|
||||
VolvoVerySlowIntervalCoordinator,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool:
|
||||
"""Set up Volvo from a config entry."""
|
||||
|
||||
api = await _async_auth_and_create_api(hass, entry)
|
||||
vehicle = await _async_load_vehicle(api)
|
||||
|
||||
# Order is important! Faster intervals must come first.
|
||||
coordinators = (
|
||||
VolvoMediumIntervalCoordinator(hass, entry, api, vehicle),
|
||||
VolvoSlowIntervalCoordinator(hass, entry, api, vehicle),
|
||||
VolvoVerySlowIntervalCoordinator(hass, entry, api, vehicle),
|
||||
)
|
||||
|
||||
await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators))
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
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."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def _async_auth_and_create_api(
|
||||
hass: HomeAssistant, entry: VolvoConfigEntry
|
||||
) -> VolvoCarsApi:
|
||||
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 in (400, 401):
|
||||
raise ConfigEntryAuthFailed from err
|
||||
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
return VolvoCarsApi(
|
||||
web_session,
|
||||
auth,
|
||||
entry.data[CONF_API_KEY],
|
||||
entry.data[CONF_VIN],
|
||||
)
|
||||
|
||||
|
||||
async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle:
|
||||
try:
|
||||
vehicle = await 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 ConfigEntryError(translation_domain=DOMAIN, translation_key="no_vehicle")
|
||||
|
||||
return vehicle
|
38
homeassistant/components/volvo/api.py
Normal file
38
homeassistant/components/volvo/api.py
Normal file
@ -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
|
37
homeassistant/components/volvo/application_credentials.py
Normal file
37
homeassistant/components/volvo/application_credentials.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""Application credentials platform for the Volvo integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL
|
||||
from volvocarsapi.scopes import DEFAULT_SCOPES
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
LocalOAuth2ImplementationWithPkce,
|
||||
)
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
|
||||
) -> VolvoOAuth2Implementation:
|
||||
"""Return auth implementation for a custom auth implementation."""
|
||||
return VolvoOAuth2Implementation(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential.client_id,
|
||||
AUTHORIZE_URL,
|
||||
TOKEN_URL,
|
||||
credential.client_secret,
|
||||
)
|
||||
|
||||
|
||||
class VolvoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
|
||||
"""Volvo oauth2 implementation."""
|
||||
|
||||
@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(DEFAULT_SCOPES),
|
||||
}
|
239
homeassistant/components/volvo/config_flow.py
Normal file
239
homeassistant/components/volvo/config_flow.py
Normal file
@ -0,0 +1,239 @@
|
||||
"""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, VolvoCarsVehicle
|
||||
|
||||
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.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .api import ConfigFlowVolvoAuth
|
||||
from .const import CONF_VIN, DOMAIN, MANUFACTURER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_volvo_cars_api(
|
||||
hass: HomeAssistant, access_token: str, api_key: str
|
||||
) -> VolvoCarsApi:
|
||||
web_session = aiohttp_client.async_get_clientsession(hass)
|
||||
auth = ConfigFlowVolvoAuth(web_session, access_token)
|
||||
return VolvoCarsApi(web_session, auth, api_key)
|
||||
|
||||
|
||||
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._vehicles: list[VolvoCarsVehicle] = []
|
||||
self._config_data: dict = {}
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return _LOGGER
|
||||
|
||||
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()
|
||||
|
||||
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
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:
|
||||
api = _create_volvo_cars_api(
|
||||
self.hass,
|
||||
self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN],
|
||||
user_input[CONF_API_KEY],
|
||||
)
|
||||
|
||||
# Try to load all vehicles on the account. If it succeeds
|
||||
# it means that the given API key is correct. The vehicle info
|
||||
# is used in the VIN step.
|
||||
try:
|
||||
await self._async_load_vehicles(api)
|
||||
except VolvoApiException:
|
||||
_LOGGER.exception("Unable to retrieve vehicles")
|
||||
errors["base"] = "cannot_load_vehicles"
|
||||
|
||||
if not errors:
|
||||
self._config_data |= user_input
|
||||
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)
|
||||
api = _create_volvo_cars_api(
|
||||
self.hass,
|
||||
self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN],
|
||||
self._config_data[CONF_API_KEY],
|
||||
)
|
||||
|
||||
# Test if the configured API key is still valid. If not, show this
|
||||
# form. If it is, skip this step and go directly to the next step.
|
||||
try:
|
||||
await self._async_load_vehicles(api)
|
||||
return await self.async_step_vin()
|
||||
except VolvoApiException:
|
||||
pass
|
||||
|
||||
elif self.source == SOURCE_RECONFIGURE:
|
||||
user_input = self._config_data = dict(
|
||||
self._get_reconfigure_entry().data
|
||||
)
|
||||
else:
|
||||
user_input = {}
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.TEXT, autocomplete="password"
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
{
|
||||
CONF_API_KEY: user_input.get(CONF_API_KEY, ""),
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="api_key",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"volvo_dev_portal": "https://developer.volvocars.com/account/#your-api-applications"
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_vin(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the VIN step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if len(self._vehicles) == 1:
|
||||
# If there is only one VIN, take that as value and
|
||||
# immediately create the entry. No need to show
|
||||
# the VIN step.
|
||||
self._config_data[CONF_VIN] = self._vehicles[0].vin
|
||||
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()
|
||||
|
||||
if user_input is not None:
|
||||
self._config_data |= user_input
|
||||
return await self._async_create_or_update()
|
||||
|
||||
if len(self._vehicles) == 0:
|
||||
errors[CONF_VIN] = "no_vehicles"
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_VIN): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(
|
||||
value=v.vin,
|
||||
label=f"{v.description.model} ({v.vin})",
|
||||
)
|
||||
for v in self._vehicles
|
||||
],
|
||||
multiple=False,
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="vin", data_schema=schema, errors=errors)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
async def _async_load_vehicles(self, api: VolvoCarsApi) -> None:
|
||||
self._vehicles = []
|
||||
vins = await api.async_get_vehicles()
|
||||
|
||||
for vin in vins:
|
||||
vehicle = await api.async_get_vehicle_details(vin)
|
||||
|
||||
if vehicle:
|
||||
self._vehicles.append(vehicle)
|
14
homeassistant/components/volvo/const.py
Normal file
14
homeassistant/components/volvo/const.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""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"
|
255
homeassistant/components/volvo/coordinator.py
Normal file
255
homeassistant/components/volvo/coordinator.py
Normal file
@ -0,0 +1,255 @@
|
||||
"""Volvo coordinators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
from volvocarsapi.models import (
|
||||
VolvoApiException,
|
||||
VolvoAuthException,
|
||||
VolvoCarsApiBaseModel,
|
||||
VolvoCarsValue,
|
||||
VolvoCarsVehicle,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DATA_BATTERY_CAPACITY, DOMAIN
|
||||
|
||||
VERY_SLOW_INTERVAL = 60
|
||||
SLOW_INTERVAL = 15
|
||||
MEDIUM_INTERVAL = 2
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type VolvoConfigEntry = ConfigEntry[tuple[VolvoBaseCoordinator, ...]]
|
||||
type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None]
|
||||
|
||||
|
||||
class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
||||
"""Volvo base coordinator."""
|
||||
|
||||
config_entry: VolvoConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: VolvoConfigEntry,
|
||||
api: VolvoCarsApi,
|
||||
vehicle: VolvoCarsVehicle,
|
||||
update_interval: timedelta,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
self.api = api
|
||||
self.vehicle = vehicle
|
||||
|
||||
self._api_calls: list[Callable[[], Coroutine[Any, Any, Any]]] = []
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
self._api_calls = await self._async_determine_api_calls()
|
||||
|
||||
if not self._api_calls:
|
||||
self.update_interval = None
|
||||
|
||||
async def _async_update_data(self) -> CoordinatorData:
|
||||
"""Fetch data from API."""
|
||||
|
||||
data: CoordinatorData = {}
|
||||
|
||||
if not self._api_calls:
|
||||
return data
|
||||
|
||||
valid = False
|
||||
exception: Exception | None = None
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(call() for call in self._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.debug(
|
||||
"%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 UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
) from result
|
||||
|
||||
data |= cast(CoordinatorData, result)
|
||||
valid = True
|
||||
|
||||
# Raise an error if not a single API call succeeded
|
||||
if not valid:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
) from exception
|
||||
|
||||
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
|
||||
|
||||
@abstractmethod
|
||||
async def _async_determine_api_calls(
|
||||
self,
|
||||
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator):
|
||||
"""Volvo coordinator with very slow update rate."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: VolvoConfigEntry,
|
||||
api: VolvoCarsApi,
|
||||
vehicle: VolvoCarsVehicle,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
entry,
|
||||
api,
|
||||
vehicle,
|
||||
timedelta(minutes=VERY_SLOW_INTERVAL),
|
||||
"Volvo very slow interval coordinator",
|
||||
)
|
||||
|
||||
async def _async_determine_api_calls(
|
||||
self,
|
||||
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
|
||||
return [
|
||||
self.api.async_get_diagnostics,
|
||||
self.api.async_get_odometer,
|
||||
self.api.async_get_statistics,
|
||||
]
|
||||
|
||||
async def _async_update_data(self) -> CoordinatorData:
|
||||
data = await super()._async_update_data()
|
||||
|
||||
# Add static values
|
||||
if self.vehicle.has_battery_engine():
|
||||
data[DATA_BATTERY_CAPACITY] = VolvoCarsValue.from_dict(
|
||||
{
|
||||
"value": self.vehicle.battery_capacity_kwh,
|
||||
}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator):
|
||||
"""Volvo coordinator with slow update rate."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: VolvoConfigEntry,
|
||||
api: VolvoCarsApi,
|
||||
vehicle: VolvoCarsVehicle,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
entry,
|
||||
api,
|
||||
vehicle,
|
||||
timedelta(minutes=SLOW_INTERVAL),
|
||||
"Volvo slow interval coordinator",
|
||||
)
|
||||
|
||||
async def _async_determine_api_calls(
|
||||
self,
|
||||
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
|
||||
if self.vehicle.has_combustion_engine():
|
||||
return [
|
||||
self.api.async_get_command_accessibility,
|
||||
self.api.async_get_fuel_status,
|
||||
]
|
||||
|
||||
return [self.api.async_get_command_accessibility]
|
||||
|
||||
|
||||
class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator):
|
||||
"""Volvo coordinator with medium update rate."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: VolvoConfigEntry,
|
||||
api: VolvoCarsApi,
|
||||
vehicle: VolvoCarsVehicle,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
entry,
|
||||
api,
|
||||
vehicle,
|
||||
timedelta(minutes=MEDIUM_INTERVAL),
|
||||
"Volvo medium interval coordinator",
|
||||
)
|
||||
|
||||
async def _async_determine_api_calls(
|
||||
self,
|
||||
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
|
||||
if self.vehicle.has_battery_engine():
|
||||
capabilities = await self.api.async_get_energy_capabilities()
|
||||
|
||||
if capabilities.get("isSupported", False):
|
||||
return [self.api.async_get_energy_state]
|
||||
|
||||
return []
|
90
homeassistant/components/volvo/entity.py
Normal file
90
homeassistant/components/volvo/entity.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Volvo entity classes."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from volvocarsapi.models import VolvoCarsApiBaseModel
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_VIN, DOMAIN, MANUFACTURER
|
||||
from .coordinator import VolvoBaseCoordinator
|
||||
|
||||
|
||||
def get_unique_id(vin: str, key: str) -> str:
|
||||
"""Get the unique ID."""
|
||||
return f"{vin}_{key}".lower()
|
||||
|
||||
|
||||
def value_to_translation_key(value: str) -> str:
|
||||
"""Make sure the translation key is valid."""
|
||||
return value.lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class VolvoEntityDescription(EntityDescription):
|
||||
"""Describes a Volvo entity."""
|
||||
|
||||
api_field: str
|
||||
|
||||
|
||||
class VolvoEntity(CoordinatorEntity[VolvoBaseCoordinator]):
|
||||
"""Volvo base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: VolvoBaseCoordinator,
|
||||
description: VolvoEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description: VolvoEntityDescription = description
|
||||
|
||||
if description.device_class != SensorDeviceClass.BATTERY:
|
||||
self._attr_translation_key = description.key
|
||||
|
||||
self._attr_unique_id = get_unique_id(
|
||||
coordinator.config_entry.data[CONF_VIN], description.key
|
||||
)
|
||||
|
||||
vehicle = coordinator.vehicle
|
||||
model = (
|
||||
f"{vehicle.description.model} ({vehicle.model_year})"
|
||||
if vehicle.fuel_type == "NONE"
|
||||
else f"{vehicle.description.model} {vehicle.fuel_type} ({vehicle.model_year})"
|
||||
)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, vehicle.vin)},
|
||||
manufacturer=MANUFACTURER,
|
||||
model=model,
|
||||
name=f"{MANUFACTURER} {vehicle.description.model}",
|
||||
serial_number=vehicle.vin,
|
||||
)
|
||||
|
||||
self._update_state(coordinator.get_api_field(description.api_field))
|
||||
|
||||
@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._update_state(api_field)
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
api_field = self.coordinator.get_api_field(self.entity_description.api_field)
|
||||
return super().available and api_field is not None
|
||||
|
||||
@abstractmethod
|
||||
def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
|
||||
"""Update the state of the entity."""
|
||||
raise NotImplementedError
|
81
homeassistant/components/volvo/icons.json
Normal file
81
homeassistant/components/volvo/icons.json
Normal file
@ -0,0 +1,81 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"charger_connection_status": {
|
||||
"default": "mdi:ev-plug-ccs2"
|
||||
},
|
||||
"charging_power": {
|
||||
"default": "mdi:gauge-empty",
|
||||
"range": {
|
||||
"1": "mdi:gauge-low",
|
||||
"4200": "mdi:gauge",
|
||||
"7400": "mdi:gauge-full"
|
||||
}
|
||||
},
|
||||
"charging_power_status": {
|
||||
"default": "mdi:power-plug-outline"
|
||||
},
|
||||
"charging_status": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"charging_type": {
|
||||
"default": "mdi:power-plug-off-outline",
|
||||
"state": {
|
||||
"ac": "mdi:current-ac",
|
||||
"dc": "mdi:current-dc"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
13
homeassistant/components/volvo/manifest.json
Normal file
13
homeassistant/components/volvo/manifest.json
Normal file
@ -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.4.1"]
|
||||
}
|
82
homeassistant/components/volvo/quality_scale.yaml
Normal file
82
homeassistant/components/volvo/quality_scale.yaml
Normal file
@ -0,0 +1,82 @@
|
||||
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: |
|
||||
Devices are handpicked because there is a rate limit on the API, which we
|
||||
would hit if all devices (vehicles) are added under the same API key.
|
||||
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: |
|
||||
Devices are handpicked. See dynamic-devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
388
homeassistant/components/volvo/sensor.py
Normal file
388
homeassistant/components/volvo/sensor.py
Normal file
@ -0,0 +1,388 @@
|
||||
"""Volvo sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from volvocarsapi.models import (
|
||||
VolvoCarsApiBaseModel,
|
||||
VolvoCarsValue,
|
||||
VolvoCarsValueField,
|
||||
VolvoCarsValueStatusField,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfEnergy,
|
||||
UnitOfEnergyDistance,
|
||||
UnitOfLength,
|
||||
UnitOfPower,
|
||||
UnitOfSpeed,
|
||||
UnitOfTime,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DATA_BATTERY_CAPACITY
|
||||
from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry
|
||||
from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription):
|
||||
"""Describes a Volvo sensor entity."""
|
||||
|
||||
source_fields: list[str] | None = None
|
||||
value_fn: Callable[[VolvoCarsValue], Any] | None = None
|
||||
|
||||
|
||||
def _availability_status(field: VolvoCarsValue) -> str:
|
||||
reason = field.get("unavailable_reason")
|
||||
return reason if reason else str(field.value)
|
||||
|
||||
|
||||
def _calculate_time_to_service(field: VolvoCarsValue) -> int:
|
||||
value = int(field.value)
|
||||
|
||||
# Always express value in days
|
||||
if isinstance(field, VolvoCarsValueField) and field.unit == "months":
|
||||
return value * 30
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _charging_power_value(field: VolvoCarsValue) -> int:
|
||||
return (
|
||||
int(field.value)
|
||||
if isinstance(field, VolvoCarsValueStatusField) and field.status == "OK"
|
||||
else 0
|
||||
)
|
||||
|
||||
|
||||
def _charging_power_status_value(field: VolvoCarsValue) -> str | None:
|
||||
status = cast(str, field.value)
|
||||
|
||||
if status.lower() in _CHARGING_POWER_STATUS_OPTIONS:
|
||||
return status
|
||||
|
||||
_LOGGER.warning(
|
||||
"Unknown value '%s' for charging_power_status. Please report it at https://github.com/home-assistant/core/issues/new?template=bug_report.yml",
|
||||
status,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
_CHARGING_POWER_STATUS_OPTIONS = ["providing_power", "no_power_available"]
|
||||
|
||||
_DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = (
|
||||
# command-accessibility endpoint
|
||||
VolvoSensorDescription(
|
||||
key="availability",
|
||||
api_field="availabilityStatus",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"available",
|
||||
"car_in_use",
|
||||
"no_internet",
|
||||
"ota_installation_in_progress",
|
||||
"power_saving_mode",
|
||||
],
|
||||
value_fn=_availability_status,
|
||||
),
|
||||
# statistics endpoint
|
||||
VolvoSensorDescription(
|
||||
key="average_energy_consumption",
|
||||
api_field="averageEnergyConsumption",
|
||||
native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# statistics endpoint
|
||||
VolvoSensorDescription(
|
||||
key="average_energy_consumption_automatic",
|
||||
api_field="averageEnergyConsumptionAutomatic",
|
||||
native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# statistics endpoint
|
||||
VolvoSensorDescription(
|
||||
key="average_energy_consumption_charge",
|
||||
api_field="averageEnergyConsumptionSinceCharge",
|
||||
native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# statistics endpoint
|
||||
VolvoSensorDescription(
|
||||
key="average_fuel_consumption",
|
||||
api_field="averageFuelConsumption",
|
||||
native_unit_of_measurement="L/100 km",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# statistics endpoint
|
||||
VolvoSensorDescription(
|
||||
key="average_fuel_consumption_automatic",
|
||||
api_field="averageFuelConsumptionAutomatic",
|
||||
native_unit_of_measurement="L/100 km",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# statistics endpoint
|
||||
VolvoSensorDescription(
|
||||
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",
|
||||
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",
|
||||
api_field=DATA_BATTERY_CAPACITY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
# fuel & energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="battery_charge_level",
|
||||
api_field="batteryChargeLevel",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="charger_connection_status",
|
||||
api_field="chargerConnectionStatus",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"connected",
|
||||
"disconnected",
|
||||
"fault",
|
||||
],
|
||||
),
|
||||
# energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="charging_current_limit",
|
||||
api_field="chargingCurrentLimit",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
),
|
||||
# energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="charging_power",
|
||||
api_field="chargingPower",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
value_fn=_charging_power_value,
|
||||
),
|
||||
# energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="charging_power_status",
|
||||
api_field="chargerPowerStatus",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=_CHARGING_POWER_STATUS_OPTIONS,
|
||||
value_fn=_charging_power_status_value,
|
||||
),
|
||||
# energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="charging_status",
|
||||
api_field="chargingStatus",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"charging",
|
||||
"discharging",
|
||||
"done",
|
||||
"error",
|
||||
"idle",
|
||||
"scheduled",
|
||||
],
|
||||
),
|
||||
# energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="charging_type",
|
||||
api_field="chargingType",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"ac",
|
||||
"dc",
|
||||
"none",
|
||||
],
|
||||
),
|
||||
# statistics & energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="distance_to_empty_battery",
|
||||
api_field="",
|
||||
source_fields=["distanceToEmptyBattery", "electricRange"],
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# statistics endpoint
|
||||
VolvoSensorDescription(
|
||||
key="distance_to_empty_tank",
|
||||
api_field="distanceToEmptyTank",
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# diagnostics endpoint
|
||||
VolvoSensorDescription(
|
||||
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",
|
||||
api_field="engineHoursToService",
|
||||
native_unit_of_measurement=UnitOfTime.HOURS,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="estimated_charging_time",
|
||||
api_field="estimatedChargingTimeToTargetBatteryChargeLevel",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# fuel endpoint
|
||||
VolvoSensorDescription(
|
||||
key="fuel_amount",
|
||||
api_field="fuelAmount",
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# odometer endpoint
|
||||
VolvoSensorDescription(
|
||||
key="odometer",
|
||||
api_field="odometer",
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
# energy state endpoint
|
||||
VolvoSensorDescription(
|
||||
key="target_battery_charge_level",
|
||||
api_field="targetBatteryChargeLevel",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
# diagnostics endpoint
|
||||
VolvoSensorDescription(
|
||||
key="time_to_service",
|
||||
api_field="timeToService",
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=_calculate_time_to_service,
|
||||
),
|
||||
# statistics endpoint
|
||||
VolvoSensorDescription(
|
||||
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",
|
||||
api_field="tripMeterManual",
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: VolvoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors."""
|
||||
|
||||
entities: list[VolvoSensor] = []
|
||||
added_keys: set[str] = set()
|
||||
|
||||
def _add_entity(
|
||||
coordinator: VolvoBaseCoordinator, description: VolvoSensorDescription
|
||||
) -> None:
|
||||
entities.append(VolvoSensor(coordinator, description))
|
||||
added_keys.add(description.key)
|
||||
|
||||
coordinators = entry.runtime_data
|
||||
|
||||
for coordinator in coordinators:
|
||||
for description in _DESCRIPTIONS:
|
||||
if description.key in added_keys:
|
||||
continue
|
||||
|
||||
if description.source_fields:
|
||||
for field in description.source_fields:
|
||||
if field in coordinator.data:
|
||||
description = replace(description, api_field=field)
|
||||
_add_entity(coordinator, description)
|
||||
elif description.api_field in coordinator.data:
|
||||
_add_entity(coordinator, description)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class VolvoSensor(VolvoEntity, SensorEntity):
|
||||
"""Volvo sensor."""
|
||||
|
||||
entity_description: VolvoSensorDescription
|
||||
|
||||
def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
|
||||
"""Update the state of the entity."""
|
||||
if api_field is None:
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
if self.device_class == SensorDeviceClass.ENUM and native_value:
|
||||
# Entities having an "unknown" value should report None as the state
|
||||
native_value = str(native_value)
|
||||
native_value = (
|
||||
value_to_translation_key(native_value)
|
||||
if native_value.upper() != "UNSPECIFIED"
|
||||
else None
|
||||
)
|
||||
|
||||
self._attr_native_value = native_value
|
178
homeassistant/components/volvo/strings.json
Normal file
178
homeassistant/components/volvo/strings.json
Normal file
@ -0,0 +1,178 @@
|
||||
{
|
||||
"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": {
|
||||
"description": "Get your API key from the [Volvo developer portal]({volvo_dev_portal}).",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The Volvo developers API key"
|
||||
}
|
||||
},
|
||||
"vin": {
|
||||
"description": "Select a vehicle",
|
||||
"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.",
|
||||
"no_vehicles": "No vehicles found on this account."
|
||||
},
|
||||
"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",
|
||||
"ota_installation_in_progress": "Installing OTA update",
|
||||
"power_saving_mode": "Power saving mode",
|
||||
"unavailable": "Unavailable"
|
||||
}
|
||||
},
|
||||
"average_energy_consumption": {
|
||||
"name": "Trip manual average energy consumption"
|
||||
},
|
||||
"average_energy_consumption_automatic": {
|
||||
"name": "Trip automatic average energy consumption"
|
||||
},
|
||||
"average_energy_consumption_charge": {
|
||||
"name": "Average energy consumption since charge"
|
||||
},
|
||||
"average_fuel_consumption": {
|
||||
"name": "Trip manual average fuel consumption"
|
||||
},
|
||||
"average_fuel_consumption_automatic": {
|
||||
"name": "Trip automatic average fuel consumption"
|
||||
},
|
||||
"average_speed": {
|
||||
"name": "Trip manual average speed"
|
||||
},
|
||||
"average_speed_automatic": {
|
||||
"name": "Trip automatic average speed"
|
||||
},
|
||||
"battery_capacity": {
|
||||
"name": "Battery capacity"
|
||||
},
|
||||
"battery_charge_level": {
|
||||
"name": "Battery charge level"
|
||||
},
|
||||
"charger_connection_status": {
|
||||
"name": "Charging connection status",
|
||||
"state": {
|
||||
"connected": "[%key:common::state::connected%]",
|
||||
"disconnected": "[%key:common::state::disconnected%]",
|
||||
"fault": "[%key:common::state::error%]"
|
||||
}
|
||||
},
|
||||
"charging_current_limit": {
|
||||
"name": "Charging limit"
|
||||
},
|
||||
"charging_power": {
|
||||
"name": "Charging power"
|
||||
},
|
||||
"charging_power_status": {
|
||||
"name": "Charging power status",
|
||||
"state": {
|
||||
"providing_power": "Providing power",
|
||||
"no_power_available": "No power"
|
||||
}
|
||||
},
|
||||
"charging_status": {
|
||||
"name": "Charging status",
|
||||
"state": {
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"discharging": "[%key:common::state::discharging%]",
|
||||
"done": "Done",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"scheduled": "Scheduled"
|
||||
}
|
||||
},
|
||||
"charging_type": {
|
||||
"name": "Charging type",
|
||||
"state": {
|
||||
"ac": "AC",
|
||||
"dc": "DC",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"distance_to_empty_battery": {
|
||||
"name": "Distance to empty battery"
|
||||
},
|
||||
"distance_to_empty_tank": {
|
||||
"name": "Distance to empty tank"
|
||||
},
|
||||
"distance_to_service": {
|
||||
"name": "Distance to service"
|
||||
},
|
||||
"engine_time_to_service": {
|
||||
"name": "Time to engine service"
|
||||
},
|
||||
"estimated_charging_time": {
|
||||
"name": "Estimated charging time"
|
||||
},
|
||||
"fuel_amount": {
|
||||
"name": "Fuel amount"
|
||||
},
|
||||
"odometer": {
|
||||
"name": "Odometer"
|
||||
},
|
||||
"target_battery_charge_level": {
|
||||
"name": "Target battery charge level"
|
||||
},
|
||||
"time_to_service": {
|
||||
"name": "Time to service"
|
||||
},
|
||||
"trip_meter_automatic": {
|
||||
"name": "Trip automatic distance"
|
||||
},
|
||||
"trip_meter_manual": {
|
||||
"name": "Trip manual distance"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"no_vehicle": {
|
||||
"message": "Unable to retrieve vehicle details."
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Unable to update data."
|
||||
},
|
||||
"unauthorized": {
|
||||
"message": "Authentication failed. {message}"
|
||||
}
|
||||
}
|
||||
}
|
@ -35,6 +35,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"spotify",
|
||||
"tesla_fleet",
|
||||
"twitch",
|
||||
"volvo",
|
||||
"weheat",
|
||||
"withings",
|
||||
"xbox",
|
||||
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -701,6 +701,7 @@ FLOWS = {
|
||||
"vodafone_station",
|
||||
"voip",
|
||||
"volumio",
|
||||
"volvo",
|
||||
"volvooncall",
|
||||
"vulcan",
|
||||
"wake_on_lan",
|
||||
|
@ -7267,6 +7267,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",
|
||||
|
10
mypy.ini
generated
10
mypy.ini
generated
@ -5229,6 +5229,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
|
||||
|
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@ -3059,6 +3059,9 @@ voip-utils==0.3.3
|
||||
# homeassistant.components.volkszaehler
|
||||
volkszaehler==0.4.0
|
||||
|
||||
# homeassistant.components.volvo
|
||||
volvocarsapi==0.4.1
|
||||
|
||||
# homeassistant.components.volvooncall
|
||||
volvooncall==0.10.3
|
||||
|
||||
|
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@ -2524,6 +2524,9 @@ vilfo-api-client==0.5.0
|
||||
# homeassistant.components.voip
|
||||
voip-utils==0.3.3
|
||||
|
||||
# homeassistant.components.volvo
|
||||
volvocarsapi==0.4.1
|
||||
|
||||
# homeassistant.components.volvooncall
|
||||
volvooncall==0.10.3
|
||||
|
||||
|
52
tests/components/volvo/__init__.py
Normal file
52
tests/components/volvo/__init__.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Tests for the Volvo integration."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from volvocarsapi.models import VolvoCarsValueField
|
||||
|
||||
from homeassistant.components.volvo.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from tests.common import async_load_fixture
|
||||
|
||||
_MODEL_SPECIFIC_RESPONSES = {
|
||||
"ex30_2024": ["energy_capabilities", "energy_state", "statistics", "vehicle"],
|
||||
"s90_diesel_2018": ["diagnostics", "statistics", "vehicle"],
|
||||
"xc40_electric_2024": [
|
||||
"energy_capabilities",
|
||||
"energy_state",
|
||||
"statistics",
|
||||
"vehicle",
|
||||
],
|
||||
"xc90_petrol_2019": ["commands", "statistics", "vehicle"],
|
||||
}
|
||||
|
||||
|
||||
async def async_load_fixture_as_json(
|
||||
hass: HomeAssistant, name: str, model: str
|
||||
) -> JsonObjectType:
|
||||
"""Load a JSON object from a fixture."""
|
||||
if name in _MODEL_SPECIFIC_RESPONSES[model]:
|
||||
name = f"{model}/{name}"
|
||||
|
||||
fixture = await async_load_fixture(hass, f"{name}.json", DOMAIN)
|
||||
return json_loads_object(fixture)
|
||||
|
||||
|
||||
async def async_load_fixture_as_value_field(
|
||||
hass: HomeAssistant, name: str, model: str
|
||||
) -> dict[str, VolvoCarsValueField]:
|
||||
"""Load a `VolvoCarsValueField` object from a fixture."""
|
||||
data = await async_load_fixture_as_json(hass, name, model)
|
||||
return {key: VolvoCarsValueField.from_dict(value) for key, value in data.items()}
|
||||
|
||||
|
||||
def configure_mock(
|
||||
mock: AsyncMock, *, return_value: Any = None, side_effect: Any = None
|
||||
) -> None:
|
||||
"""Reconfigure mock."""
|
||||
mock.reset_mock()
|
||||
mock.side_effect = side_effect
|
||||
mock.return_value = return_value
|
185
tests/components/volvo/conftest.py
Normal file
185
tests/components/volvo/conftest.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""Define fixtures for Volvo unit tests."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
from volvocarsapi.auth import TOKEN_URL
|
||||
from volvocarsapi.models import (
|
||||
VolvoCarsAvailableCommand,
|
||||
VolvoCarsLocation,
|
||||
VolvoCarsValueField,
|
||||
VolvoCarsVehicle,
|
||||
)
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.volvo.const import CONF_VIN, DOMAIN
|
||||
from homeassistant.const import CONF_API_KEY, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import async_load_fixture_as_json, async_load_fixture_as_value_field
|
||||
from .const import (
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
DEFAULT_API_KEY,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_VIN,
|
||||
MOCK_ACCESS_TOKEN,
|
||||
SERVER_TOKEN_RESPONSE,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
@pytest.fixture(params=[DEFAULT_MODEL])
|
||||
def full_model(request: pytest.FixtureRequest) -> str:
|
||||
"""Define which model to use when running the test. Use as a decorator."""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DEFAULT_VIN,
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
CONF_API_KEY: DEFAULT_API_KEY,
|
||||
CONF_VIN: DEFAULT_VIN,
|
||||
CONF_TOKEN: {
|
||||
"access_token": MOCK_ACCESS_TOKEN,
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_at": 123456789,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
return config_entry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def mock_api(hass: HomeAssistant, full_model: str) -> AsyncGenerator[AsyncMock]:
|
||||
"""Mock the Volvo API."""
|
||||
with patch(
|
||||
"homeassistant.components.volvo.VolvoCarsApi",
|
||||
autospec=True,
|
||||
) as mock_api:
|
||||
vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model)
|
||||
vehicle = VolvoCarsVehicle.from_dict(vehicle_data)
|
||||
|
||||
commands_data = (
|
||||
await async_load_fixture_as_json(hass, "commands", full_model)
|
||||
).get("data")
|
||||
commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data]
|
||||
|
||||
location_data = await async_load_fixture_as_json(hass, "location", full_model)
|
||||
location = {"location": VolvoCarsLocation.from_dict(location_data)}
|
||||
|
||||
availability = await async_load_fixture_as_value_field(
|
||||
hass, "availability", full_model
|
||||
)
|
||||
brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model)
|
||||
diagnostics = await async_load_fixture_as_value_field(
|
||||
hass, "diagnostics", full_model
|
||||
)
|
||||
doors = await async_load_fixture_as_value_field(hass, "doors", full_model)
|
||||
energy_capabilities = await async_load_fixture_as_json(
|
||||
hass, "energy_capabilities", full_model
|
||||
)
|
||||
energy_state_data = await async_load_fixture_as_json(
|
||||
hass, "energy_state", full_model
|
||||
)
|
||||
energy_state = {
|
||||
key: VolvoCarsValueField.from_dict(value)
|
||||
for key, value in energy_state_data.items()
|
||||
}
|
||||
engine_status = await async_load_fixture_as_value_field(
|
||||
hass, "engine_status", full_model
|
||||
)
|
||||
engine_warnings = await async_load_fixture_as_value_field(
|
||||
hass, "engine_warnings", full_model
|
||||
)
|
||||
fuel_status = await async_load_fixture_as_value_field(
|
||||
hass, "fuel_status", full_model
|
||||
)
|
||||
odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model)
|
||||
recharge_status = await async_load_fixture_as_value_field(
|
||||
hass, "recharge_status", full_model
|
||||
)
|
||||
statistics = await async_load_fixture_as_value_field(
|
||||
hass, "statistics", full_model
|
||||
)
|
||||
tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model)
|
||||
warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model)
|
||||
windows = await async_load_fixture_as_value_field(hass, "windows", full_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_energy_capabilities = AsyncMock(return_value=energy_capabilities)
|
||||
api.async_get_energy_state = AsyncMock(return_value=energy_state)
|
||||
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 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 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
|
19
tests/components/volvo/const.py
Normal file
19
tests/components/volvo/const.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Define const for Volvo unit tests."""
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
|
||||
DEFAULT_API_KEY = "abcdef0123456879abcdef"
|
||||
DEFAULT_MODEL = "xc40_electric_2024"
|
||||
DEFAULT_VIN = "YV1ABCDEFG1234567"
|
||||
|
||||
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,
|
||||
}
|
6
tests/components/volvo/fixtures/availability.json
Normal file
6
tests/components/volvo/fixtures/availability.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"availabilityStatus": {
|
||||
"value": "AVAILABLE",
|
||||
"timestamp": "2024-12-30T14:32:26.169Z"
|
||||
}
|
||||
}
|
6
tests/components/volvo/fixtures/brakes.json
Normal file
6
tests/components/volvo/fixtures/brakes.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"brakeFluidLevelWarning": {
|
||||
"value": "NO_WARNING",
|
||||
"timestamp": "2024-12-30T14:18:56.849Z"
|
||||
}
|
||||
}
|
36
tests/components/volvo/fixtures/commands.json
Normal file
36
tests/components/volvo/fixtures/commands.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
25
tests/components/volvo/fixtures/diagnostics.json
Normal file
25
tests/components/volvo/fixtures/diagnostics.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
34
tests/components/volvo/fixtures/doors.json
Normal file
34
tests/components/volvo/fixtures/doors.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
33
tests/components/volvo/fixtures/energy_capabilities.json
Normal file
33
tests/components/volvo/fixtures/energy_capabilities.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"isSupported": false,
|
||||
"batteryChargeLevel": {
|
||||
"isSupported": false
|
||||
},
|
||||
"electricRange": {
|
||||
"isSupported": false
|
||||
},
|
||||
"chargerConnectionStatus": {
|
||||
"isSupported": false
|
||||
},
|
||||
"chargingSystemStatus": {
|
||||
"isSupported": false
|
||||
},
|
||||
"chargingType": {
|
||||
"isSupported": false
|
||||
},
|
||||
"chargerPowerStatus": {
|
||||
"isSupported": false
|
||||
},
|
||||
"estimatedChargingTimeToTargetBatteryChargeLevel": {
|
||||
"isSupported": false
|
||||
},
|
||||
"targetBatteryChargeLevel": {
|
||||
"isSupported": false
|
||||
},
|
||||
"chargingCurrentLimit": {
|
||||
"isSupported": false
|
||||
},
|
||||
"chargingPower": {
|
||||
"isSupported": false
|
||||
}
|
||||
}
|
42
tests/components/volvo/fixtures/energy_state.json
Normal file
42
tests/components/volvo/fixtures/energy_state.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"batteryChargeLevel": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"electricRange": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"chargerConnectionStatus": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"chargingStatus": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"chargingType": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"chargerPowerStatus": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"estimatedChargingTimeToTargetBatteryChargeLevel": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"chargingCurrentLimit": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"targetBatteryChargeLevel": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
},
|
||||
"chargingPower": {
|
||||
"status": "ERROR",
|
||||
"code": "NOT_SUPPORTED"
|
||||
}
|
||||
}
|
6
tests/components/volvo/fixtures/engine_status.json
Normal file
6
tests/components/volvo/fixtures/engine_status.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"engineStatus": {
|
||||
"value": "STOPPED",
|
||||
"timestamp": "2024-12-30T15:00:00.000Z"
|
||||
}
|
||||
}
|
10
tests/components/volvo/fixtures/engine_warnings.json
Normal file
10
tests/components/volvo/fixtures/engine_warnings.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
{
|
||||
"isSupported": true,
|
||||
"batteryChargeLevel": {
|
||||
"isSupported": true
|
||||
},
|
||||
"electricRange": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargerConnectionStatus": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargingSystemStatus": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargingType": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargerPowerStatus": {
|
||||
"isSupported": true
|
||||
},
|
||||
"estimatedChargingTimeToTargetBatteryChargeLevel": {
|
||||
"isSupported": true
|
||||
},
|
||||
"targetBatteryChargeLevel": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargingCurrentLimit": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargingPower": {
|
||||
"isSupported": true
|
||||
}
|
||||
}
|
57
tests/components/volvo/fixtures/ex30_2024/energy_state.json
Normal file
57
tests/components/volvo/fixtures/ex30_2024/energy_state.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"batteryChargeLevel": {
|
||||
"status": "OK",
|
||||
"value": 38,
|
||||
"unit": "percentage",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"electricRange": {
|
||||
"status": "OK",
|
||||
"value": 90,
|
||||
"unit": "km",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargerConnectionStatus": {
|
||||
"status": "OK",
|
||||
"value": "DISCONNECTED",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargingStatus": {
|
||||
"status": "OK",
|
||||
"value": "IDLE",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargingType": {
|
||||
"status": "OK",
|
||||
"value": "NONE",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargerPowerStatus": {
|
||||
"status": "OK",
|
||||
"value": "NO_POWER_AVAILABLE",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"estimatedChargingTimeToTargetBatteryChargeLevel": {
|
||||
"status": "OK",
|
||||
"value": 0,
|
||||
"unit": "minutes",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargingCurrentLimit": {
|
||||
"status": "OK",
|
||||
"value": 32,
|
||||
"unit": "ampere",
|
||||
"updatedAt": "2024-03-05T08:38:44Z"
|
||||
},
|
||||
"targetBatteryChargeLevel": {
|
||||
"status": "OK",
|
||||
"value": 90,
|
||||
"unit": "percentage",
|
||||
"updatedAt": "2024-09-22T09:40:12Z"
|
||||
},
|
||||
"chargingPower": {
|
||||
"status": "ERROR",
|
||||
"code": "PROPERTY_NOT_FOUND",
|
||||
"message": "No valid value could be found for the requested property"
|
||||
}
|
||||
}
|
32
tests/components/volvo/fixtures/ex30_2024/statistics.json
Normal file
32
tests/components/volvo/fixtures/ex30_2024/statistics.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"averageEnergyConsumption": {
|
||||
"value": 22.6,
|
||||
"unit": "kWh/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"
|
||||
}
|
||||
}
|
17
tests/components/volvo/fixtures/ex30_2024/vehicle.json
Normal file
17
tests/components/volvo/fixtures/ex30_2024/vehicle.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
12
tests/components/volvo/fixtures/fuel_status.json
Normal file
12
tests/components/volvo/fixtures/fuel_status.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
11
tests/components/volvo/fixtures/location.json
Normal file
11
tests/components/volvo/fixtures/location.json
Normal file
@ -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]
|
||||
}
|
||||
}
|
7
tests/components/volvo/fixtures/odometer.json
Normal file
7
tests/components/volvo/fixtures/odometer.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"odometer": {
|
||||
"value": 30000,
|
||||
"unit": "km",
|
||||
"timestamp": "2024-12-30T14:18:56.849Z"
|
||||
}
|
||||
}
|
25
tests/components/volvo/fixtures/recharge_status.json
Normal file
25
tests/components/volvo/fixtures/recharge_status.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
16
tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json
Normal file
16
tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
18
tests/components/volvo/fixtures/tyres.json
Normal file
18
tests/components/volvo/fixtures/tyres.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
94
tests/components/volvo/fixtures/warnings.json
Normal file
94
tests/components/volvo/fixtures/warnings.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
22
tests/components/volvo/fixtures/windows.json
Normal file
22
tests/components/volvo/fixtures/windows.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
{
|
||||
"isSupported": true,
|
||||
"batteryChargeLevel": {
|
||||
"isSupported": true
|
||||
},
|
||||
"electricRange": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargerConnectionStatus": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargingSystemStatus": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargingType": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargerPowerStatus": {
|
||||
"isSupported": true
|
||||
},
|
||||
"estimatedChargingTimeToTargetBatteryChargeLevel": {
|
||||
"isSupported": true
|
||||
},
|
||||
"targetBatteryChargeLevel": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargingCurrentLimit": {
|
||||
"isSupported": true
|
||||
},
|
||||
"chargingPower": {
|
||||
"isSupported": true
|
||||
}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
{
|
||||
"batteryChargeLevel": {
|
||||
"status": "OK",
|
||||
"value": 53,
|
||||
"unit": "percentage",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"electricRange": {
|
||||
"status": "OK",
|
||||
"value": 220,
|
||||
"unit": "km",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargerConnectionStatus": {
|
||||
"status": "OK",
|
||||
"value": "CONNECTED",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargingStatus": {
|
||||
"status": "OK",
|
||||
"value": "CHARGING",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargingType": {
|
||||
"status": "OK",
|
||||
"value": "AC",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargerPowerStatus": {
|
||||
"status": "OK",
|
||||
"value": "PROVIDING_POWER",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"estimatedChargingTimeToTargetBatteryChargeLevel": {
|
||||
"status": "OK",
|
||||
"value": 1440,
|
||||
"unit": "minutes",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
},
|
||||
"chargingCurrentLimit": {
|
||||
"status": "OK",
|
||||
"value": 32,
|
||||
"unit": "ampere",
|
||||
"updatedAt": "2024-03-05T08:38:44Z"
|
||||
},
|
||||
"targetBatteryChargeLevel": {
|
||||
"status": "OK",
|
||||
"value": 90,
|
||||
"unit": "percentage",
|
||||
"updatedAt": "2024-09-22T09:40:12Z"
|
||||
},
|
||||
"chargingPower": {
|
||||
"status": "OK",
|
||||
"value": 1386,
|
||||
"unit": "watts",
|
||||
"updatedAt": "2025-07-02T08:51:23Z"
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
{
|
||||
"averageEnergyConsumption": {
|
||||
"value": 22.6,
|
||||
"unit": "kWh/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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
{
|
||||
"averageFuelConsumption": {
|
||||
"value": 9.59,
|
||||
"unit": "l/100km",
|
||||
"timestamp": "2024-12-30T14:53:44.785Z"
|
||||
},
|
||||
"averageSpeed": {
|
||||
"value": 66,
|
||||
"unit": "km/h",
|
||||
"timestamp": "2024-12-30T14:18:56.849Z"
|
||||
},
|
||||
"averageSpeedAutomatic": {
|
||||
"value": 77,
|
||||
"unit": "km/h",
|
||||
"timestamp": "2024-12-30T14:18:56.849Z"
|
||||
},
|
||||
"distanceToEmptyTank": {
|
||||
"value": 253,
|
||||
"unit": "km",
|
||||
"timestamp": "2024-12-30T14:30:08.338Z"
|
||||
},
|
||||
"tripMeterManual": {
|
||||
"value": 178.9,
|
||||
"unit": "km",
|
||||
"timestamp": "2024-12-30T14:18:56.849Z"
|
||||
},
|
||||
"tripMeterAutomatic": {
|
||||
"value": 4.2,
|
||||
"unit": "km",
|
||||
"timestamp": "2024-12-30T14:18:56.849Z"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
3833
tests/components/volvo/snapshots/test_sensor.ambr
Normal file
3833
tests/components/volvo/snapshots/test_sensor.ambr
Normal file
File diff suppressed because it is too large
Load Diff
303
tests/components/volvo/test_config_flow.py
Normal file
303
tests/components/volvo/test_config_flow.py
Normal file
@ -0,0 +1,303 @@
|
||||
"""Test the Volvo config flow."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL
|
||||
from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle
|
||||
from volvocarsapi.scopes import DEFAULT_SCOPES
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant import config_entries
|
||||
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 . import async_load_fixture_as_json, configure_mock
|
||||
from .const import (
|
||||
CLIENT_ID,
|
||||
DEFAULT_API_KEY,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_VIN,
|
||||
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,
|
||||
mock_config_flow_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Check full flow."""
|
||||
result = await _async_run_flow_to_completion(
|
||||
hass, config_flow, mock_config_flow_api
|
||||
)
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_API_KEY] == DEFAULT_API_KEY
|
||||
assert result["data"][CONF_VIN] == DEFAULT_VIN
|
||||
assert result["context"]["unique_id"] == DEFAULT_VIN
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_single_vin_flow(
|
||||
hass: HomeAssistant,
|
||||
config_flow: ConfigFlowResult,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_flow_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Check flow where API returns a single VIN."""
|
||||
_configure_mock_vehicles_success(mock_config_flow_api, single_vin=True)
|
||||
|
||||
# Since there is only one VIN, the api_key step is the only step
|
||||
result = await hass.config_entries.flow.async_configure(config_flow["flow_id"])
|
||||
assert result["step_id"] == "api_key"
|
||||
|
||||
result = 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
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("api_key_failure"), [pytest.param(True), pytest.param(False)])
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_reauth_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
mock_config_flow_api: VolvoCarsApi,
|
||||
api_key_failure: bool,
|
||||
) -> 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,
|
||||
mock_config_flow_api,
|
||||
has_vin_step=False,
|
||||
is_reauth=True,
|
||||
api_key_failure=api_key_failure,
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_reconfigure_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_config_flow_api: VolvoCarsApi,
|
||||
) -> 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"
|
||||
|
||||
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,
|
||||
mock_config_flow_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Test unique ID flow."""
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
|
||||
result = await _async_run_flow_to_completion(
|
||||
hass, config_flow, mock_config_flow_api
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["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,
|
||||
mock_config_flow_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Check flow where API throws an exception."""
|
||||
_configure_mock_vehicles_failure(mock_config_flow_api)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(config_flow["flow_id"])
|
||||
assert result["step_id"] == "api_key"
|
||||
|
||||
result = 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 result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "cannot_load_vehicles"
|
||||
assert result["step_id"] == "api_key"
|
||||
|
||||
result = await _async_run_flow_to_completion(
|
||||
hass, result, mock_config_flow_api, configure=False
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@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(DEFAULT_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 mock_config_flow_api(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]:
|
||||
"""Mock API used in config flow."""
|
||||
with patch(
|
||||
"homeassistant.components.volvo.config_flow.VolvoCarsApi",
|
||||
autospec=True,
|
||||
) as mock_api:
|
||||
api: VolvoCarsApi = mock_api.return_value
|
||||
|
||||
_configure_mock_vehicles_success(api)
|
||||
|
||||
vehicle_data = await async_load_fixture_as_json(hass, "vehicle", DEFAULT_MODEL)
|
||||
configure_mock(
|
||||
api.async_get_vehicle_details,
|
||||
return_value=VolvoCarsVehicle.from_dict(vehicle_data),
|
||||
)
|
||||
|
||||
yield api
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def mock_auth_client(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> AsyncGenerator[AsyncMock]:
|
||||
"""Mock auth requests."""
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
TOKEN_URL,
|
||||
json=SERVER_TOKEN_RESPONSE,
|
||||
)
|
||||
|
||||
|
||||
async def _async_run_flow_to_completion(
|
||||
hass: HomeAssistant,
|
||||
config_flow: ConfigFlowResult,
|
||||
mock_config_flow_api: VolvoCarsApi,
|
||||
*,
|
||||
configure: bool = True,
|
||||
has_vin_step: bool = True,
|
||||
is_reauth: bool = False,
|
||||
api_key_failure: bool = False,
|
||||
) -> ConfigFlowResult:
|
||||
if configure:
|
||||
if api_key_failure:
|
||||
_configure_mock_vehicles_failure(mock_config_flow_api)
|
||||
|
||||
config_flow = await hass.config_entries.flow.async_configure(
|
||||
config_flow["flow_id"]
|
||||
)
|
||||
|
||||
if is_reauth and not api_key_failure:
|
||||
return config_flow
|
||||
|
||||
assert config_flow["type"] is FlowResultType.FORM
|
||||
assert config_flow["step_id"] == "api_key"
|
||||
|
||||
_configure_mock_vehicles_success(mock_config_flow_api)
|
||||
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: DEFAULT_VIN}
|
||||
)
|
||||
|
||||
return config_flow
|
||||
|
||||
|
||||
def _configure_mock_vehicles_success(
|
||||
mock_config_flow_api: VolvoCarsApi, single_vin: bool = False
|
||||
) -> None:
|
||||
vins = [{"vin": DEFAULT_VIN}]
|
||||
|
||||
if not single_vin:
|
||||
vins.append({"vin": "YV10000000AAAAAAA"})
|
||||
|
||||
configure_mock(mock_config_flow_api.async_get_vehicles, return_value=vins)
|
||||
|
||||
|
||||
def _configure_mock_vehicles_failure(mock_config_flow_api: VolvoCarsApi) -> None:
|
||||
configure_mock(
|
||||
mock_config_flow_api.async_get_vehicles, side_effect=VolvoApiException()
|
||||
)
|
151
tests/components/volvo/test_coordinator.py
Normal file
151
tests/components/volvo/test_coordinator.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""Test Volvo coordinator."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
from volvocarsapi.models import (
|
||||
VolvoApiException,
|
||||
VolvoAuthException,
|
||||
VolvoCarsValueField,
|
||||
)
|
||||
|
||||
from homeassistant.components.volvo.coordinator import VERY_SLOW_INTERVAL
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import configure_mock
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
|
||||
async def test_coordinator_update(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
mock_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Test coordinator update."""
|
||||
assert await setup_integration()
|
||||
|
||||
sensor_id = "sensor.volvo_xc40_odometer"
|
||||
interval = timedelta(minutes=VERY_SLOW_INTERVAL)
|
||||
value = {"odometer": VolvoCarsValueField(value=30000, unit="km")}
|
||||
mock_method: AsyncMock = mock_api.async_get_odometer
|
||||
|
||||
state = hass.states.get(sensor_id)
|
||||
assert state.state == "30000"
|
||||
|
||||
value["odometer"].value = 30001
|
||||
configure_mock(mock_method, return_value=value)
|
||||
freezer.tick(interval)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
assert mock_method.call_count == 1
|
||||
state = hass.states.get(sensor_id)
|
||||
assert state.state == "30001"
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
|
||||
async def test_coordinator_with_errors(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
mock_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Test coordinator with errors."""
|
||||
assert await setup_integration()
|
||||
|
||||
sensor_id = "sensor.volvo_xc40_odometer"
|
||||
interval = timedelta(minutes=VERY_SLOW_INTERVAL)
|
||||
value = {"odometer": VolvoCarsValueField(value=30000, unit="km")}
|
||||
mock_method: AsyncMock = mock_api.async_get_odometer
|
||||
|
||||
state = hass.states.get(sensor_id)
|
||||
assert state.state == "30000"
|
||||
|
||||
configure_mock(mock_method, side_effect=VolvoApiException())
|
||||
freezer.tick(interval)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
assert mock_method.call_count == 1
|
||||
state = hass.states.get(sensor_id)
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
configure_mock(mock_method, return_value=value)
|
||||
freezer.tick(interval)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
assert mock_method.call_count == 1
|
||||
state = hass.states.get(sensor_id)
|
||||
assert state.state == "30000"
|
||||
|
||||
configure_mock(mock_method, side_effect=Exception())
|
||||
freezer.tick(interval)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
assert mock_method.call_count == 1
|
||||
state = hass.states.get(sensor_id)
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
configure_mock(mock_method, return_value=value)
|
||||
freezer.tick(interval)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
assert mock_method.call_count == 1
|
||||
state = hass.states.get(sensor_id)
|
||||
assert state.state == "30000"
|
||||
|
||||
configure_mock(mock_method, side_effect=VolvoAuthException())
|
||||
freezer.tick(interval)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
assert mock_method.call_count == 1
|
||||
state = hass.states.get(sensor_id)
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
|
||||
async def test_update_coordinator_all_error(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
mock_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Test API returning error for all calls during coordinator update."""
|
||||
assert await setup_integration()
|
||||
|
||||
_mock_api_failure(mock_api)
|
||||
freezer.tick(timedelta(minutes=VERY_SLOW_INTERVAL))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
for state in hass.states.async_all():
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
def _mock_api_failure(mock_api: VolvoCarsApi) -> AsyncMock:
|
||||
"""Mock the Volvo API so that it raises an exception for all calls."""
|
||||
|
||||
mock_api.async_get_brakes_status.side_effect = VolvoApiException()
|
||||
mock_api.async_get_command_accessibility.side_effect = VolvoApiException()
|
||||
mock_api.async_get_commands.side_effect = VolvoApiException()
|
||||
mock_api.async_get_diagnostics.side_effect = VolvoApiException()
|
||||
mock_api.async_get_doors_status.side_effect = VolvoApiException()
|
||||
mock_api.async_get_energy_capabilities.side_effect = VolvoApiException()
|
||||
mock_api.async_get_energy_state.side_effect = VolvoApiException()
|
||||
mock_api.async_get_engine_status.side_effect = VolvoApiException()
|
||||
mock_api.async_get_engine_warnings.side_effect = VolvoApiException()
|
||||
mock_api.async_get_fuel_status.side_effect = VolvoApiException()
|
||||
mock_api.async_get_location.side_effect = VolvoApiException()
|
||||
mock_api.async_get_odometer.side_effect = VolvoApiException()
|
||||
mock_api.async_get_recharge_status.side_effect = VolvoApiException()
|
||||
mock_api.async_get_statistics.side_effect = VolvoApiException()
|
||||
mock_api.async_get_tyre_states.side_effect = VolvoApiException()
|
||||
mock_api.async_get_warnings.side_effect = VolvoApiException()
|
||||
mock_api.async_get_window_states.side_effect = VolvoApiException()
|
||||
|
||||
return mock_api
|
125
tests/components/volvo/test_init.py
Normal file
125
tests/components/volvo/test_init.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Test Volvo init."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
from volvocarsapi.auth import TOKEN_URL
|
||||
from volvocarsapi.models import VolvoAuthException
|
||||
|
||||
from homeassistant.components.volvo.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import configure_mock
|
||||
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.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(
|
||||
hass: HomeAssistant,
|
||||
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
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert flows
|
||||
assert flows[0]["handler"] == DOMAIN
|
||||
assert flows[0]["step_id"] == "reauth_confirm"
|
||||
|
||||
|
||||
async def test_no_vehicle(
|
||||
mock_config_entry: MockConfigEntry,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
mock_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Test no vehicle during coordinator setup."""
|
||||
mock_method: AsyncMock = mock_api.async_get_vehicle_details
|
||||
|
||||
configure_mock(mock_method, return_value=None, side_effect=None)
|
||||
assert not await setup_integration()
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_vehicle_auth_failure(
|
||||
mock_config_entry: MockConfigEntry,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
mock_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Test auth failure during coordinator setup."""
|
||||
mock_method: AsyncMock = mock_api.async_get_vehicle_details
|
||||
|
||||
configure_mock(mock_method, return_value=None, side_effect=VolvoAuthException())
|
||||
assert not await setup_integration()
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
32
tests/components/volvo/test_sensor.py
Normal file
32
tests/components/volvo/test_sensor.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""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 tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"full_model",
|
||||
["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"],
|
||||
)
|
||||
async def test_sensor(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test sensor."""
|
||||
|
||||
with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]):
|
||||
assert await setup_integration()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
Loading…
x
Reference in New Issue
Block a user