mirror of
https://github.com/home-assistant/core.git
synced 2025-07-29 16:17:20 +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.velbus.*
|
||||||
homeassistant.components.vlc_telnet.*
|
homeassistant.components.vlc_telnet.*
|
||||||
homeassistant.components.vodafone_station.*
|
homeassistant.components.vodafone_station.*
|
||||||
|
homeassistant.components.volvo.*
|
||||||
homeassistant.components.wake_on_lan.*
|
homeassistant.components.wake_on_lan.*
|
||||||
homeassistant.components.wake_word.*
|
homeassistant.components.wake_word.*
|
||||||
homeassistant.components.wallbox.*
|
homeassistant.components.wallbox.*
|
||||||
|
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1706,6 +1706,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/voip/ @balloob @synesthesiam @jaminh
|
/tests/components/voip/ @balloob @synesthesiam @jaminh
|
||||||
/homeassistant/components/volumio/ @OnFreund
|
/homeassistant/components/volumio/ @OnFreund
|
||||||
/tests/components/volumio/ @OnFreund
|
/tests/components/volumio/ @OnFreund
|
||||||
|
/homeassistant/components/volvo/ @thomasddn
|
||||||
|
/tests/components/volvo/ @thomasddn
|
||||||
/homeassistant/components/volvooncall/ @molobrakos
|
/homeassistant/components/volvooncall/ @molobrakos
|
||||||
/tests/components/volvooncall/ @molobrakos
|
/tests/components/volvooncall/ @molobrakos
|
||||||
/homeassistant/components/vulcan/ @Antoni-Czaplicki
|
/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",
|
"spotify",
|
||||||
"tesla_fleet",
|
"tesla_fleet",
|
||||||
"twitch",
|
"twitch",
|
||||||
|
"volvo",
|
||||||
"weheat",
|
"weheat",
|
||||||
"withings",
|
"withings",
|
||||||
"xbox",
|
"xbox",
|
||||||
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -701,6 +701,7 @@ FLOWS = {
|
|||||||
"vodafone_station",
|
"vodafone_station",
|
||||||
"voip",
|
"voip",
|
||||||
"volumio",
|
"volumio",
|
||||||
|
"volvo",
|
||||||
"volvooncall",
|
"volvooncall",
|
||||||
"vulcan",
|
"vulcan",
|
||||||
"wake_on_lan",
|
"wake_on_lan",
|
||||||
|
@ -7267,6 +7267,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"volvo": {
|
||||||
|
"name": "Volvo",
|
||||||
|
"integration_type": "device",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "cloud_polling"
|
||||||
|
},
|
||||||
"volvooncall": {
|
"volvooncall": {
|
||||||
"name": "Volvo On Call",
|
"name": "Volvo On Call",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
10
mypy.ini
generated
10
mypy.ini
generated
@ -5229,6 +5229,16 @@ disallow_untyped_defs = true
|
|||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = 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.*]
|
[mypy-homeassistant.components.wake_on_lan.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_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
|
# homeassistant.components.volkszaehler
|
||||||
volkszaehler==0.4.0
|
volkszaehler==0.4.0
|
||||||
|
|
||||||
|
# homeassistant.components.volvo
|
||||||
|
volvocarsapi==0.4.1
|
||||||
|
|
||||||
# homeassistant.components.volvooncall
|
# homeassistant.components.volvooncall
|
||||||
volvooncall==0.10.3
|
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
|
# homeassistant.components.voip
|
||||||
voip-utils==0.3.3
|
voip-utils==0.3.3
|
||||||
|
|
||||||
|
# homeassistant.components.volvo
|
||||||
|
volvocarsapi==0.4.1
|
||||||
|
|
||||||
# homeassistant.components.volvooncall
|
# homeassistant.components.volvooncall
|
||||||
volvooncall==0.10.3
|
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