diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py index 82eb87374f5fbf..3c5e1d74f49770 100644 --- a/homeassistant/components/volvo/const.py +++ b/homeassistant/components/volvo/const.py @@ -7,6 +7,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.DEVICE_TRACKER, + Platform.LOCK, Platform.SENSOR, ] diff --git a/homeassistant/components/volvo/lock.py b/homeassistant/components/volvo/lock.py new file mode 100644 index 00000000000000..27b1872a71c043 --- /dev/null +++ b/homeassistant/components/volvo/lock.py @@ -0,0 +1,136 @@ +"""Volvo locks.""" + +from dataclasses import dataclass +import logging +from typing import Any, cast + +from volvocarsapi.models import VolvoApiException, VolvoCarsApiBaseModel, VolvoCarsValue + +from homeassistant.components.lock import LockEntity, LockEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import VolvoConfigEntry +from .entity import VolvoEntity, VolvoEntityDescription + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VolvoLockDescription(VolvoEntityDescription, LockEntityDescription): + """Describes a Volvo lock entity.""" + + api_lock_value: str = "LOCKED" + api_unlock_value: str = "UNLOCKED" + lock_command: str + unlock_command: str + required_command_key: str + + +_DESCRIPTIONS: tuple[VolvoLockDescription, ...] = ( + VolvoLockDescription( + key="lock", + api_field="centralLock", + lock_command="lock", + unlock_command="unlock", + required_command_key="LOCK", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VolvoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up locks.""" + coordinators = entry.runtime_data.interval_coordinators + async_add_entities( + [ + VolvoLock(coordinator, description) + for coordinator in coordinators + for description in _DESCRIPTIONS + if description.required_command_key + in entry.runtime_data.context.supported_commands + and description.api_field in coordinator.data + ] + ) + + +class VolvoLock(VolvoEntity, LockEntity): + """Volvo lock.""" + + entity_description: VolvoLockDescription + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the car.""" + await self._async_handle_command(self.entity_description.lock_command, True) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the car.""" + await self._async_handle_command(self.entity_description.unlock_command, False) + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + """Update the state of the entity.""" + assert isinstance(api_field, VolvoCarsValue) + self._attr_is_locked = api_field.value == "LOCKED" + + async def _async_handle_command(self, command: str, locked: bool) -> None: + _LOGGER.debug("Lock '%s' is %s", command, "locked" if locked else "unlocked") + if locked: + self._attr_is_locking = True + else: + self._attr_is_unlocking = True + self.async_write_ha_state() + + try: + result = await self.coordinator.context.api.async_execute_command(command) + except VolvoApiException as ex: + _LOGGER.debug("Lock '%s' error", command) + error = self._reset_and_create_error(command, message=ex.message) + raise error from ex + + status = result.invoke_status if result else "" + _LOGGER.debug("Lock '%s' result: %s", command, status) + + if status.upper() not in ("COMPLETED", "DELIVERED"): + error = self._reset_and_create_error( + command, status=status, message=result.message if result else "" + ) + raise error + + api_field = cast( + VolvoCarsValue, + self.coordinator.get_api_field(self.entity_description.api_field), + ) + + self._attr_is_locking = False + self._attr_is_unlocking = False + + if locked: + api_field.value = self.entity_description.api_lock_value + else: + api_field.value = self.entity_description.api_unlock_value + + self._attr_is_locked = locked + self.async_write_ha_state() + + def _reset_and_create_error( + self, command: str, *, status: str = "", message: str = "" + ) -> HomeAssistantError: + self._attr_is_locking = False + self._attr_is_unlocking = False + self.async_write_ha_state() + + return HomeAssistantError( + translation_domain=DOMAIN, + translation_key="lock_failure", + translation_placeholders={ + "command": command, + "status": status, + "message": message, + }, + ) diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index ecc562fab57778..e4c4ac7efcf8c0 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -210,6 +210,11 @@ "name": "Honk & flash" } }, + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, "sensor": { "availability": { "name": "Car connection", @@ -349,6 +354,9 @@ "command_failure": { "message": "Command {command} failed. Status: {status}. Message: {message}" }, + "lock_failure": { + "message": "Failed to {command} vehicle. Status: {status}. Message: {message}" + }, "no_vehicle": { "message": "Unable to retrieve vehicle details." }, diff --git a/tests/components/volvo/snapshots/test_lock.ambr b/tests/components/volvo/snapshots/test_lock.ambr new file mode 100644 index 00000000000000..c1cde82920f292 --- /dev/null +++ b/tests/components/volvo/snapshots/test_lock.ambr @@ -0,0 +1,197 @@ +# serializer version: 1 +# name: test_lock[ex30_2024][lock.volvo_ex30_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.volvo_ex30_lock', + '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': 'Lock', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'yv1abcdefg1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[ex30_2024][lock.volvo_ex30_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.volvo_ex30_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_lock[s90_diesel_2018][lock.volvo_s90_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.volvo_s90_lock', + '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': 'Lock', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'yv1abcdefg1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[s90_diesel_2018][lock.volvo_s90_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.volvo_s90_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_lock[xc40_electric_2024][lock.volvo_xc40_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.volvo_xc40_lock', + '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': 'Lock', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'yv1abcdefg1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[xc40_electric_2024][lock.volvo_xc40_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.volvo_xc40_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_lock[xc90_petrol_2019][lock.volvo_xc90_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.volvo_xc90_lock', + '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': 'Lock', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'yv1abcdefg1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[xc90_petrol_2019][lock.volvo_xc90_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.volvo_xc90_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/volvo/test_lock.py b/tests/components/volvo/test_lock.py new file mode 100644 index 00000000000000..dfabc82d6547e7 --- /dev/null +++ b/tests/components/volvo/test_lock.py @@ -0,0 +1,136 @@ +"""Test Volvo locks.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoApiException, VolvoCarsCommandResult + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, + LockState, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import configure_mock +from .const import DEFAULT_VIN + +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_lock( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lock.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]): + assert await setup_integration() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("full_model") +@pytest.mark.parametrize( + "action", + [SERVICE_UNLOCK, SERVICE_LOCK], +) +async def test_unlock_lock( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, + action: str, +) -> None: + """Test unlock/lock.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]): + assert await setup_integration() + + await hass.services.async_call( + LOCK_DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.volvo_xc40_lock"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_api.async_execute_command.assert_called_once_with(action) + + +@pytest.mark.usefixtures("full_model") +@pytest.mark.parametrize( + "action", + [SERVICE_UNLOCK, SERVICE_LOCK], +) +async def test_unlock_lock_error( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, + action: str, +) -> None: + """Test unlock/lock with error response.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]): + assert await setup_integration() + + configure_mock(mock_api.async_execute_command, side_effect=VolvoApiException) + + entity_id = "lock.volvo_xc40_lock" + assert hass.states.get(entity_id).state == LockState.LOCKED + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LOCK_DOMAIN, + action, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == LockState.LOCKED + + +@pytest.mark.usefixtures("full_model") +async def test_unlock_failure( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test unlock/lock with error response.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]): + assert await setup_integration() + + configure_mock( + mock_api.async_execute_command, + return_value=VolvoCarsCommandResult( + vin=DEFAULT_VIN, invoke_status="CONNECTION_FAILURE", message="" + ), + ) + + entity_id = "lock.volvo_xc40_lock" + assert hass.states.get(entity_id).state == LockState.LOCKED + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == LockState.LOCKED