Add device_tracker platform to Volvo integration (#153437)

This commit is contained in:
Thomas D
2025-10-10 16:23:07 +02:00
committed by GitHub
parent 517124dfbe
commit c7321a337e
16 changed files with 812 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):

View File

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

View File

@@ -307,6 +307,9 @@
"dc": "mdi:current-dc"
}
},
"direction": {
"default": "mdi:compass-outline"
},
"distance_to_empty_battery": {
"default": "mdi:battery-outline"
},

View File

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

View File

@@ -268,6 +268,9 @@
"none": "None"
}
},
"direction": {
"name": "Direction"
},
"distance_to_empty_battery": {
"name": "Distance to empty battery"
},

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'device_tracker',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'device_tracker.volvo_ex30_none',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SourceType.GPS: 'gps'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.volvo_ex30_none',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'device_tracker',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'device_tracker.volvo_s90_none',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SourceType.GPS: 'gps'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.volvo_s90_none',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'device_tracker',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'device_tracker.volvo_xc40_none',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SourceType.GPS: 'gps'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.volvo_xc40_none',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'device_tracker',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'device_tracker.volvo_xc90_none',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SourceType.GPS: 'gps'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.volvo_xc90_none',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'not_home',
})
# ---

View File

@@ -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({

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.volvo_ex30_direction',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.volvo_ex30_direction',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.volvo_s90_direction',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.volvo_s90_direction',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.volvo_xc40_direction',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.volvo_xc40_direction',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.volvo_xc60_direction',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.volvo_xc60_direction',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.volvo_xc90_direction',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.volvo_xc90_direction',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.volvo_xc90_direction',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.volvo_xc90_direction',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '90',
})
# ---
# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

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

View File

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

View File

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

View File

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