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:
Thomas D 2025-07-28 17:24:15 +02:00 committed by GitHub
parent 8f795f021c
commit 483d814a8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 7070 additions and 0 deletions

View File

@ -547,6 +547,7 @@ homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
homeassistant.components.volvo.*
homeassistant.components.wake_on_lan.*
homeassistant.components.wake_word.*
homeassistant.components.wallbox.*

2
CODEOWNERS generated
View File

@ -1706,6 +1706,8 @@ build.json @home-assistant/supervisor
/tests/components/voip/ @balloob @synesthesiam @jaminh
/homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund
/homeassistant/components/volvo/ @thomasddn
/tests/components/volvo/ @thomasddn
/homeassistant/components/volvooncall/ @molobrakos
/tests/components/volvooncall/ @molobrakos
/homeassistant/components/vulcan/ @Antoni-Czaplicki

View 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

View 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

View 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),
}

View 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)

View 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"

View 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 []

View 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

View 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"
}
}
}
}

View 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"]
}

View 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

View 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

View 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}"
}
}
}

View File

@ -35,6 +35,7 @@ APPLICATION_CREDENTIALS = [
"spotify",
"tesla_fleet",
"twitch",
"volvo",
"weheat",
"withings",
"xbox",

View File

@ -701,6 +701,7 @@ FLOWS = {
"vodafone_station",
"voip",
"volumio",
"volvo",
"volvooncall",
"vulcan",
"wake_on_lan",

View File

@ -7267,6 +7267,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"volvo": {
"name": "Volvo",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},
"volvooncall": {
"name": "Volvo On Call",
"integration_type": "hub",

10
mypy.ini generated
View File

@ -5229,6 +5229,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.volvo.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.wake_on_lan.*]
check_untyped_defs = true
disallow_incomplete_defs = true

3
requirements_all.txt generated
View File

@ -3059,6 +3059,9 @@ voip-utils==0.3.3
# homeassistant.components.volkszaehler
volkszaehler==0.4.0
# homeassistant.components.volvo
volvocarsapi==0.4.1
# homeassistant.components.volvooncall
volvooncall==0.10.3

View File

@ -2524,6 +2524,9 @@ vilfo-api-client==0.5.0
# homeassistant.components.voip
voip-utils==0.3.3
# homeassistant.components.volvo
volvocarsapi==0.4.1
# homeassistant.components.volvooncall
volvooncall==0.10.3

View 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

View 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

View 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,
}

View File

@ -0,0 +1,6 @@
{
"availabilityStatus": {
"value": "AVAILABLE",
"timestamp": "2024-12-30T14:32:26.169Z"
}
}

View File

@ -0,0 +1,6 @@
{
"brakeFluidLevelWarning": {
"value": "NO_WARNING",
"timestamp": "2024-12-30T14:18:56.849Z"
}
}

View 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"
}
]
}

View 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"
}
}

View 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"
}
}

View 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
}
}

View 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"
}
}

View File

@ -0,0 +1,6 @@
{
"engineStatus": {
"value": "STOPPED",
"timestamp": "2024-12-30T15:00:00.000Z"
}
}

View 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"
}
}

View File

@ -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
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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]
}
}

View File

@ -0,0 +1,7 @@
{
"odometer": {
"value": 30000,
"unit": "km",
"timestamp": "2024-12-30T14:18:56.849Z"
}
}

View 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"
}
}

View 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": 17,
"unit": "days",
"timestamp": "2024-12-30T14:18:56.849Z"
}
}

View File

@ -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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View File

@ -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
}
}

View File

@ -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"
}
}

View 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"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
]
}

View File

@ -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"
}
}

View File

@ -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"
}
}

File diff suppressed because it is too large Load Diff

View 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()
)

View 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

View 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

View 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)