mirror of
https://github.com/home-assistant/core.git
synced 2025-11-06 17:40:11 +00:00
Add device_tracker platform to Volvo integration (#153437)
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
59
homeassistant/components/volvo/device_tracker.py
Normal file
59
homeassistant/components/volvo/device_tracker.py
Normal 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]
|
||||
@@ -307,6 +307,9 @@
|
||||
"dc": "mdi:current-dc"
|
||||
}
|
||||
},
|
||||
"direction": {
|
||||
"default": "mdi:compass-outline"
|
||||
},
|
||||
"distance_to_empty_battery": {
|
||||
"default": "mdi:battery-outline"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -268,6 +268,9 @@
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"direction": {
|
||||
"name": "Direction"
|
||||
},
|
||||
"distance_to_empty_battery": {
|
||||
"name": "Distance to empty battery"
|
||||
},
|
||||
|
||||
209
tests/components/volvo/snapshots/test_device_tracker.ambr
Normal file
209
tests/components/volvo/snapshots/test_device_tracker.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
33
tests/components/volvo/test_device_tracker.py
Normal file
33
tests/components/volvo/test_device_tracker.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user