diff --git a/homeassistant/components/volvo/application_credentials.py b/homeassistant/components/volvo/application_credentials.py index 18dae40f8ee..bfc48a1ee00 100644 --- a/homeassistant/components/volvo/application_credentials.py +++ b/homeassistant/components/volvo/application_credentials.py @@ -3,7 +3,7 @@ from __future__ import annotations from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL -from volvocarsapi.scopes import DEFAULT_SCOPES +from volvocarsapi.scopes import ALL_SCOPES from homeassistant.components.application_credentials import ClientCredential from homeassistant.core import HomeAssistant @@ -33,5 +33,5 @@ class VolvoOAuth2Implementation(LocalOAuth2ImplementationWithPkce): 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), + "scope": " ".join(ALL_SCOPES), } diff --git a/homeassistant/components/volvo/binary_sensor.py b/homeassistant/components/volvo/binary_sensor.py index fe8783d9334..ed71a515226 100644 --- a/homeassistant/components/volvo/binary_sensor.py +++ b/homeassistant/components/volvo/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API_NONE_VALUE -from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry +from .coordinator import VolvoConfigEntry from .entity import VolvoEntity, VolvoEntityDescription PARALLEL_UPDATES = 0 @@ -380,16 +380,6 @@ class VolvoBinarySensor(VolvoEntity, BinarySensorEntity): entity_description: VolvoBinarySensorDescription - def __init__( - self, - coordinator: VolvoBaseCoordinator, - description: VolvoBinarySensorDescription, - ) -> None: - """Initialize entity.""" - self._attr_extra_state_attributes = {} - - super().__init__(coordinator, description) - def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: """Update the state of the entity.""" if api_field is None: diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py index 0ae0e54077e..9f38c16b4fe 100644 --- a/homeassistant/components/volvo/config_flow.py +++ b/homeassistant/components/volvo/config_flow.py @@ -9,7 +9,7 @@ from typing import Any import voluptuous as vol from volvocarsapi.api import VolvoCarsApi from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle -from volvocarsapi.scopes import DEFAULT_SCOPES +from volvocarsapi.scopes import ALL_SCOPES from homeassistant.config_entries import ( SOURCE_REAUTH, @@ -59,7 +59,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): 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), + "scope": " ".join(ALL_SCOPES), } @property diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py index 512dc5e0804..03fd2169253 100644 --- a/homeassistant/components/volvo/const.py +++ b/homeassistant/components/volvo/const.py @@ -3,7 +3,11 @@ from homeassistant.const import Platform DOMAIN = "volvo" -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.SENSOR, +] API_NONE_VALUE = "UNSPECIFIED" CONF_VIN = "vin" diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index fa4de64e052..852567881b4 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -22,7 +22,7 @@ from volvocarsapi.models import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_BATTERY_CAPACITY, DOMAIN @@ -119,7 +119,16 @@ class VolvoBaseIntervalCoordinator(VolvoBaseCoordinator[CoordinatorData]): self._api_calls: list[Callable[[], Coroutine[Any, Any, Any]]] = [] async def _async_setup(self) -> None: - self._api_calls = await self._async_determine_api_calls() + try: + self._api_calls = await self._async_determine_api_calls() + except VolvoAuthException as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": err.message}, + ) from err + except VolvoApiException as err: + raise ConfigEntryNotReady from err if not self._api_calls: self.update_interval = None @@ -153,7 +162,9 @@ class VolvoBaseIntervalCoordinator(VolvoBaseCoordinator[CoordinatorData]): result.message, ) raise ConfigEntryAuthFailed( - f"Authentication failed. {result.message}" + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": result.message}, ) from result if isinstance(result, VolvoApiException): @@ -270,14 +281,17 @@ class VolvoSlowIntervalCoordinator(VolvoBaseIntervalCoordinator): self, ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: api = self.context.api + api_calls: list[Any] = [api.async_get_command_accessibility] + + location = await api.async_get_location() + + if location.get("location") is not None: + api_calls.append(api.async_get_location) if self.context.vehicle.has_combustion_engine(): - return [ - api.async_get_command_accessibility, - api.async_get_fuel_status, - ] + api_calls.append(api.async_get_fuel_status) - return [api.async_get_command_accessibility] + return api_calls class VolvoMediumIntervalCoordinator(VolvoBaseIntervalCoordinator): diff --git a/homeassistant/components/volvo/device_tracker.py b/homeassistant/components/volvo/device_tracker.py new file mode 100644 index 00000000000..44078c9387d --- /dev/null +++ b/homeassistant/components/volvo/device_tracker.py @@ -0,0 +1,59 @@ +"""Volvo device tracker.""" + +from dataclasses import dataclass + +from volvocarsapi.models import VolvoCarsApiBaseModel, VolvoCarsLocation + +from homeassistant.components.device_tracker.config_entry import ( + TrackerEntity, + TrackerEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import VolvoConfigEntry +from .entity import VolvoEntity, VolvoEntityDescription + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class VolvoTrackerDescription(VolvoEntityDescription, TrackerEntityDescription): + """Describes a Volvo Cars tracker entity.""" + + +_DESCRIPTIONS: tuple[VolvoTrackerDescription, ...] = ( + VolvoTrackerDescription( + key="location", + api_field="location", + ), +) + + +async def async_setup_entry( + _: HomeAssistant, + entry: VolvoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up tracker.""" + + coordinators = entry.runtime_data.interval_coordinators + async_add_entities( + VolvoDeviceTracker(coordinator, description) + for coordinator in coordinators + for description in _DESCRIPTIONS + if description.api_field in coordinator.data + ) + + +class VolvoDeviceTracker(VolvoEntity, TrackerEntity): + """Volvo tracker.""" + + entity_description: VolvoTrackerDescription + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + assert isinstance(api_field, VolvoCarsLocation) + + if api_field.geometry.coordinates and len(api_field.geometry.coordinates) > 1: + self._attr_longitude = api_field.geometry.coordinates[0] + self._attr_latitude = api_field.geometry.coordinates[1] diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json index 13d1882d848..0ac29dbc0f7 100644 --- a/homeassistant/components/volvo/icons.json +++ b/homeassistant/components/volvo/icons.json @@ -307,6 +307,9 @@ "dc": "mdi:current-dc" } }, + "direction": { + "default": "mdi:compass-outline" + }, "distance_to_empty_battery": { "default": "mdi:battery-outline" }, diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index f104fabf83b..d9d455f1cde 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -5,10 +5,11 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, cast +from typing import cast from volvocarsapi.models import ( VolvoCarsApiBaseModel, + VolvoCarsLocation, VolvoCarsValue, VolvoCarsValueField, VolvoCarsValueStatusField, @@ -21,6 +22,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + DEGREE, PERCENTAGE, EntityCategory, UnitOfElectricCurrent, @@ -34,6 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import API_NONE_VALUE, DATA_BATTERY_CAPACITY from .coordinator import VolvoConfigEntry @@ -47,25 +50,31 @@ _LOGGER = logging.getLogger(__name__) class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): """Describes a Volvo sensor entity.""" - value_fn: Callable[[VolvoCarsValue], Any] | None = None + value_fn: Callable[[VolvoCarsApiBaseModel], StateType] | None = None -def _availability_status(field: VolvoCarsValue) -> str: +def _availability_status(field: VolvoCarsApiBaseModel) -> str: reason = field.get("unavailable_reason") - return reason if reason else str(field.value) + + if reason: + return str(reason) + + if isinstance(field, VolvoCarsValue): + return str(field.value) + + return "" -def _calculate_time_to_service(field: VolvoCarsValue) -> int: +def _calculate_time_to_service(field: VolvoCarsApiBaseModel) -> int: + if not isinstance(field, VolvoCarsValueField): + return 0 + value = int(field.value) - # Always express value in days - if isinstance(field, VolvoCarsValueField) and field.unit == "months": - return value * 30 - - return value + return value * 30 if field.unit == "months" else value -def _charging_power_value(field: VolvoCarsValue) -> int: +def _charging_power_value(field: VolvoCarsApiBaseModel) -> int: return ( field.value if isinstance(field, VolvoCarsValueStatusField) and isinstance(field.value, int) @@ -73,8 +82,8 @@ def _charging_power_value(field: VolvoCarsValue) -> int: ) -def _charging_power_status_value(field: VolvoCarsValue) -> str | None: - status = cast(str, field.value) +def _charging_power_status_value(field: VolvoCarsApiBaseModel) -> str | None: + status = cast(str, field.value) if isinstance(field, VolvoCarsValue) else "" if status.lower() in _CHARGING_POWER_STATUS_OPTIONS: return status @@ -86,6 +95,10 @@ def _charging_power_status_value(field: VolvoCarsValue) -> str | None: return None +def _direction_value(field: VolvoCarsApiBaseModel) -> str | None: + return field.properties.heading if isinstance(field, VolvoCarsLocation) else None + + _CHARGING_POWER_STATUS_OPTIONS = [ "fault", "power_available_but_not_activated", @@ -245,6 +258,14 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( "none", ], ), + # location endpoint + VolvoSensorDescription( + key="direction", + api_field="location", + native_unit_of_measurement=DEGREE, + suggested_display_precision=0, + value_fn=_direction_value, + ), # statistics endpoint # We're not using `electricRange` from the energy state endpoint because # the official app seems to use `distanceToEmptyBattery`. @@ -380,13 +401,12 @@ class VolvoSensor(VolvoEntity, SensorEntity): self._attr_native_value = None return - assert isinstance(api_field, VolvoCarsValue) + native_value = None - native_value = ( - api_field.value - if self.entity_description.value_fn is None - else self.entity_description.value_fn(api_field) - ) + if self.entity_description.value_fn: + native_value = self.entity_description.value_fn(api_field) + elif isinstance(api_field, VolvoCarsValue): + native_value = api_field.value if self.device_class == SensorDeviceClass.ENUM and native_value: # Entities having an "unknown" value should report None as the state diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index f10888ac325..2685f98b2b7 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -268,6 +268,9 @@ "none": "None" } }, + "direction": { + "name": "Direction" + }, "distance_to_empty_battery": { "name": "Distance to empty battery" }, diff --git a/tests/components/volvo/snapshots/test_device_tracker.ambr b/tests/components/volvo/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..dd76a630125 --- /dev/null +++ b/tests/components/volvo/snapshots/test_device_tracker.ambr @@ -0,0 +1,209 @@ +# serializer version: 1 +# name: test_device_tracker[ex30_2024][device_tracker.volvo_ex30_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.volvo_ex30_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'yv1abcdefg1234567_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[ex30_2024][device_tracker.volvo_ex30_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 None', + 'gps_accuracy': 0, + 'latitude': 57.72537482589284, + 'longitude': 11.849843629550225, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.volvo_ex30_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[s90_diesel_2018][device_tracker.volvo_s90_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.volvo_s90_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'yv1abcdefg1234567_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[s90_diesel_2018][device_tracker.volvo_s90_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 None', + 'gps_accuracy': 0, + 'latitude': 57.72537482589284, + 'longitude': 11.849843629550225, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.volvo_s90_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[xc40_electric_2024][device_tracker.volvo_xc40_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.volvo_xc40_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'yv1abcdefg1234567_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[xc40_electric_2024][device_tracker.volvo_xc40_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 None', + 'gps_accuracy': 0, + 'latitude': 57.72537482589284, + 'longitude': 11.849843629550225, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.volvo_xc40_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[xc90_petrol_2019][device_tracker.volvo_xc90_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.volvo_xc90_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'yv1abcdefg1234567_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[xc90_petrol_2019][device_tracker.volvo_xc90_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 None', + 'gps_accuracy': 0, + 'latitude': 57.72537482589284, + 'longitude': 11.849843629550225, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.volvo_xc90_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/volvo/snapshots/test_diagnostics.ambr b/tests/components/volvo/snapshots/test_diagnostics.ambr index 67f59c44a19..8b98a697829 100644 --- a/tests/components/volvo/snapshots/test_diagnostics.ambr +++ b/tests/components/volvo/snapshots/test_diagnostics.ambr @@ -194,6 +194,23 @@ 'unit': None, 'value': 'AVAILABLE', }), + 'location': dict({ + 'extra_data': dict({ + }), + 'geometry': dict({ + 'coordinates': '**REDACTED**', + 'extra_data': dict({ + 'type': 'Point', + }), + }), + 'properties': dict({ + 'extra_data': dict({ + }), + 'heading': '**REDACTED**', + 'timestamp': '2024-12-30T15:00:00+00:00', + }), + 'type': 'Feature', + }), }), 'Volvo very slow interval coordinator': dict({ 'averageEnergyConsumption': dict({ diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index a8c1f10357a..6075f7400dc 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -476,6 +476,58 @@ 'state': 'ac', }) # --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1263,6 +1315,58 @@ 'state': 'available', }) # --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_empty_tank-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2411,6 +2515,58 @@ 'state': 'ac', }) # --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_empty_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3377,6 +3533,58 @@ 'state': 'idle', }) # --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC60 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4164,6 +4372,58 @@ 'state': 'available', }) # --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_empty_tank-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5200,6 +5460,58 @@ 'state': 'none', }) # --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': 'yv1abcdefg1234567_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- # name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py index 3129b1383fe..c33331d5629 100644 --- a/tests/components/volvo/test_config_flow.py +++ b/tests/components/volvo/test_config_flow.py @@ -7,7 +7,7 @@ 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 volvocarsapi.scopes import ALL_SCOPES from yarl import URL from homeassistant import config_entries @@ -251,7 +251,7 @@ async def config_flow( 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) + assert result_url.query["scope"] == " ".join(ALL_SCOPES) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") diff --git a/tests/components/volvo/test_coordinator.py b/tests/components/volvo/test_coordinator.py index 271693a18d1..20e13c575d0 100644 --- a/tests/components/volvo/test_coordinator.py +++ b/tests/components/volvo/test_coordinator.py @@ -13,6 +13,7 @@ from volvocarsapi.models import ( VolvoCarsValueField, ) +from homeassistant.components.volvo.const import DOMAIN from homeassistant.components.volvo.coordinator import VERY_SLOW_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -123,7 +124,7 @@ async def test_update_coordinator_all_error( 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(): + for state in hass.states.async_all(domain_filter=DOMAIN): assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/volvo/test_device_tracker.py b/tests/components/volvo/test_device_tracker.py new file mode 100644 index 00000000000..5d0d7148e49 --- /dev/null +++ b/tests/components/volvo/test_device_tracker.py @@ -0,0 +1,33 @@ +"""Test Volvo device tracker.""" + +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.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + ["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"], +) +async def test_device_tracker( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device tracker.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.DEVICE_TRACKER]): + assert await setup_integration() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index 05571ff8cac..ca577c12892 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -1,13 +1,19 @@ """Test Volvo sensors.""" from collections.abc import Awaitable, Callable -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoCarsErrorResult, + VolvoCarsValue, + VolvoCarsValueField, +) from homeassistant.components.volvo.const import DOMAIN -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -116,3 +122,96 @@ async def test_unique_ids( assert await setup_integration() assert f"Platform {DOMAIN} does not generate unique IDs" not in caplog.text + + +async def test_availability_status_reason( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test availability_status entity returns unavailable reason.""" + + mock_method: AsyncMock = mock_api.async_get_command_accessibility + mock_method.return_value["availabilityStatus"] = VolvoCarsValue( + value="UNAVAILABLE", extra_data={"unavailable_reason": "no_internet"} + ) + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_car_connection") + assert state.state == "no_internet" + + +async def test_time_to_service_non_value_field( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test time_to_service entity with non-VolvoCarsValueField returns 0.""" + + mock_method: AsyncMock = mock_api.async_get_diagnostics + mock_method.return_value["timeToService"] = VolvoCarsErrorResult(message="invalid") + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_time_to_service") + assert state.state == "0" + + +async def test_time_to_service_months_conversion( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test time_to_service entity converts months to days.""" + + mock_method: AsyncMock = mock_api.async_get_diagnostics + mock_method.return_value["timeToService"] = VolvoCarsValueField( + value=3, unit="months" + ) + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_time_to_service") + assert state.state == "90" + + +async def test_charging_power_value_fallback( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test charging_power entity returns 0 for invalid field types.""" + + mock_method: AsyncMock = mock_api.async_get_energy_state + mock_method.return_value["chargingPower"] = VolvoCarsErrorResult(message="invalid") + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_charging_power") + assert state.state == "0" + + +async def test_charging_power_status_unknown_value( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test charging_power_status entity with unknown status logs warning.""" + + mock_method: AsyncMock = mock_api.async_get_energy_state + mock_method.return_value["chargerPowerStatus"] = VolvoCarsValue( + value="unknown_status" + ) + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + state = hass.states.get("sensor.volvo_xc40_charging_power_status") + assert state.state == STATE_UNKNOWN + assert "Unknown value 'unknown_status' for charging_power_status" in caplog.text