From 1e5c76715863a40ae74eba9238dd18595c126f04 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 15 Nov 2021 15:50:43 +0100 Subject: [PATCH] Add button entities to Renault (#59383) Co-authored-by: epenet --- .../components/renault/binary_sensor.py | 4 +- homeassistant/components/renault/button.py | 83 ++++++++ homeassistant/components/renault/const.py | 2 + .../components/renault/device_tracker.py | 6 +- .../components/renault/renault_entities.py | 38 ++-- homeassistant/components/renault/select.py | 6 +- homeassistant/components/renault/sensor.py | 6 +- homeassistant/components/renault/services.py | 8 + tests/components/renault/const.py | 51 +++++ tests/components/renault/test_button.py | 185 ++++++++++++++++++ tests/components/renault/test_services.py | 5 +- 11 files changed, 371 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/renault/button.py create mode 100644 tests/components/renault/test_button.py diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 2799289fc1d..a054cba2f12 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -33,7 +33,7 @@ class RenaultBinarySensorRequiredKeysMixin: @dataclass class RenaultBinarySensorEntityDescription( BinarySensorEntityDescription, - RenaultEntityDescription, + RenaultDataEntityDescription, RenaultBinarySensorRequiredKeysMixin, ): """Class describing Renault binary sensor entities.""" diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py new file mode 100644 index 00000000000..e62bdf083ae --- /dev/null +++ b/homeassistant/components/renault/button.py @@ -0,0 +1,83 @@ +"""Support for Renault button entities.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .renault_entities import RenaultEntity +from .renault_hub import RenaultHub + + +@dataclass +class RenaultButtonRequiredKeysMixin: + """Mixin for required keys.""" + + async_press: Callable[[RenaultButtonEntity], Awaitable] + + +@dataclass +class RenaultButtonEntityDescription( + ButtonEntityDescription, RenaultButtonRequiredKeysMixin +): + """Class describing Renault button entities.""" + + requires_electricity: bool = False + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities: list[RenaultButtonEntity] = [ + RenaultButtonEntity(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in BUTTON_TYPES + if not description.requires_electricity or vehicle.details.uses_electricity() + ] + async_add_entities(entities) + + +class RenaultButtonEntity(RenaultEntity, ButtonEntity): + """Mixin for button specific attributes.""" + + entity_description: RenaultButtonEntityDescription + + async def async_press(self) -> None: + """Process the button press.""" + await self.entity_description.async_press(self) + + +async def _start_charge(entity: RenaultButtonEntity) -> None: + """Start charge on the vehicle.""" + await entity.vehicle.vehicle.set_charge_start() + + +async def _start_air_conditioner(entity: RenaultButtonEntity) -> None: + """Start air conditioner on the vehicle.""" + await entity.vehicle.vehicle.set_ac_start(21, None) + + +BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = ( + RenaultButtonEntityDescription( + async_press=_start_air_conditioner, + key="start_air_conditioner", + icon="mdi:air-conditioner", + name="Start Air Conditioner", + ), + RenaultButtonEntityDescription( + async_press=_start_charge, + key="start_charge", + icon="mdi:ev-station", + name="Start Charge", + requires_electricity=True, + ), +) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 4c1376288f0..2a0ea3a0d49 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -1,5 +1,6 @@ """Constants for the Renault component.""" from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -13,6 +14,7 @@ DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ BINARY_SENSOR_DOMAIN, + BUTTON_DOMAIN, DEVICE_TRACKER_DOMAIN, SELECT_DOMAIN, SENSOR_DOMAIN, diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 466a1f9e4a6..3e9a2608f80 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -51,8 +51,8 @@ class RenaultDeviceTracker( return SOURCE_TYPE_GPS -DEVICE_TRACKER_TYPES: tuple[RenaultEntityDescription, ...] = ( - RenaultEntityDescription( +DEVICE_TRACKER_TYPES: tuple[RenaultDataEntityDescription, ...] = ( + RenaultDataEntityDescription( key="location", coordinator="location", icon="mdi:car", diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index b963edbc81f..14ebcf2c2e4 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -14,40 +14,33 @@ from .renault_vehicle import RenaultVehicleProxy @dataclass -class RenaultRequiredKeysMixin: +class RenaultDataRequiredKeysMixin: """Mixin for required keys.""" coordinator: str @dataclass -class RenaultEntityDescription(EntityDescription, RenaultRequiredKeysMixin): - """Class describing Renault entities.""" +class RenaultDataEntityDescription(EntityDescription, RenaultDataRequiredKeysMixin): + """Class describing Renault data entities.""" -class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): +class RenaultEntity(Entity): """Implementation of a Renault entity with a data coordinator.""" - entity_description: RenaultEntityDescription + entity_description: EntityDescription def __init__( self, vehicle: RenaultVehicleProxy, - description: RenaultEntityDescription, + description: EntityDescription, ) -> None: """Initialise entity.""" - super().__init__(vehicle.coordinators[description.coordinator]) self.vehicle = vehicle self.entity_description = description self._attr_device_info = self.vehicle.device_info self._attr_unique_id = f"{self.vehicle.details.vin}_{description.key}".lower() - def _get_data_attr(self, key: str) -> StateType: - """Return the attribute value from the coordinator data.""" - if self.coordinator.data is None: - return None - return cast(StateType, getattr(self.coordinator.data, key)) - @property def name(self) -> str: """Return the name of the entity. @@ -55,3 +48,22 @@ class RenaultDataEntity(CoordinatorEntity[Optional[T]], Entity): Overridden to include the device name. """ return f"{self.vehicle.device_info[ATTR_NAME]} {self.entity_description.name}" + + +class RenaultDataEntity(CoordinatorEntity[Optional[T]], RenaultEntity): + """Implementation of a Renault entity with a data coordinator.""" + + def __init__( + self, + vehicle: RenaultVehicleProxy, + description: RenaultDataEntityDescription, + ) -> None: + """Initialise entity.""" + super().__init__(vehicle.coordinators[description.coordinator]) + RenaultEntity.__init__(self, vehicle, description) + + def _get_data_attr(self, key: str) -> StateType: + """Return the attribute value from the coordinator data.""" + if self.coordinator.data is None: + return None + return cast(StateType, getattr(self.coordinator.data, key)) diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index a8f4a15dc21..e7ec97b3927 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DEVICE_CLASS_CHARGE_MODE, DOMAIN -from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub @@ -29,7 +29,9 @@ class RenaultSelectRequiredKeysMixin: @dataclass class RenaultSelectEntityDescription( - SelectEntityDescription, RenaultEntityDescription, RenaultSelectRequiredKeysMixin + SelectEntityDescription, + RenaultDataEntityDescription, + RenaultSelectRequiredKeysMixin, ): """Class describing Renault select entities.""" diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index e8e26e06d6c..d06ae497cf1 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -43,7 +43,7 @@ from homeassistant.util.dt import as_utc, parse_datetime from .const import DEVICE_CLASS_CHARGE_STATE, DEVICE_CLASS_PLUG_STATE, DOMAIN from .renault_coordinator import T -from .renault_entities import RenaultDataEntity, RenaultEntityDescription +from .renault_entities import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy @@ -58,7 +58,9 @@ class RenaultSensorRequiredKeysMixin: @dataclass class RenaultSensorEntityDescription( - SensorEntityDescription, RenaultEntityDescription, RenaultSensorRequiredKeysMixin + SensorEntityDescription, + RenaultDataEntityDescription, + RenaultSensorRequiredKeysMixin, ): """Class describing Renault sensor entities.""" diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index 972befcec6d..de69daefef6 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -112,6 +112,14 @@ def setup_services(hass: HomeAssistant) -> None: async def charge_start(service_call: ServiceCall) -> None: """Start charge.""" + # The Renault start charge service has been replaced by a + # dedicated button entity and marked as deprecated + LOGGER.warning( + "The 'renault.charge_start' service is deprecated and " + "replaced by a dedicated start charge button entity; please " + "use that entity to start the charge instead" + ) + proxy = get_vehicle_proxy(service_call.data) LOGGER.debug("Charge start attempt") diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index e3703173ad0..e0283867132 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -4,6 +4,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PLUG, DOMAIN as BINARY_SENSOR_DOMAIN, ) +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.renault.const import ( CONF_KAMEREON_ACCOUNT_ID, @@ -117,6 +118,20 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging", }, ], + BUTTON_DOMAIN: [ + { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ICON: "mdi:air-conditioner", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner", + }, + { + ATTR_ENTITY_ID: "button.reg_number_start_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", + }, + ], DEVICE_TRACKER_DOMAIN: [], SELECT_DOMAIN: [ { @@ -251,6 +266,20 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging", }, ], + BUTTON_DOMAIN: [ + { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ICON: "mdi:air-conditioner", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner", + }, + { + ATTR_ENTITY_ID: "button.reg_number_start_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", + }, + ], DEVICE_TRACKER_DOMAIN: [ { ATTR_ENTITY_ID: "device_tracker.reg_number_location", @@ -391,6 +420,20 @@ MOCK_VEHICLES = { ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging", }, ], + BUTTON_DOMAIN: [ + { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ICON: "mdi:air-conditioner", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner", + }, + { + ATTR_ENTITY_ID: "button.reg_number_start_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_charge", + }, + ], DEVICE_TRACKER_DOMAIN: [ { ATTR_ENTITY_ID: "device_tracker.reg_number_location", @@ -532,6 +575,14 @@ MOCK_VEHICLES = { "location": "location.json", }, BINARY_SENSOR_DOMAIN: [], + BUTTON_DOMAIN: [ + { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ICON: "mdi:air-conditioner", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner", + }, + ], DEVICE_TRACKER_DOMAIN: [ { ATTR_ENTITY_ID: "device_tracker.reg_number_location", diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py new file mode 100644 index 00000000000..729eb89d74c --- /dev/null +++ b/tests/components/renault/test_button.py @@ -0,0 +1,185 @@ +"""Tests for Renault sensors.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import schemas + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button.const import SERVICE_PRESS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import check_device_registry, check_entities_no_data +from .const import ATTR_ENTITY_ID, MOCK_VEHICLES + +from tests.common import load_fixture, mock_device_registry, mock_registry + +pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") + + +@pytest.fixture(autouse=True) +def override_platforms(): + """Override PLATFORMS.""" + with patch("homeassistant.components.renault.PLATFORMS", [BUTTON_DOMAIN]): + yield + + +@pytest.mark.usefixtures("fixtures_with_data") +async def test_buttons( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BUTTON_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_no_data") +async def test_button_empty( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers with empty data from Renault.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BUTTON_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +async def test_button_errors( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers with temporary failure.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BUTTON_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_access_denied_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_access_denied( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers with access denied failure.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BUTTON_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_not_supported_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_not_supported( + hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str +): + """Test for Renault device trackers with not supported failure.""" + + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + expected_entities = mock_vehicle[BUTTON_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + + check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + + +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_start_charge(hass: HomeAssistant, config_entry: ConfigEntry): + """Test that button invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_ENTITY_ID: "button.reg_number_start_charge", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_start_air_conditioner( + hass: HomeAssistant, config_entry: ConfigEntry +): + """Test that button invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_start", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_start.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (21, None) diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 5a02fd814b9..b7748cafb5d 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -236,7 +236,9 @@ async def test_service_set_charge_schedule_multi( assert mock_action.mock_calls[0][1] == (mock_call_data,) -async def test_service_set_charge_start(hass: HomeAssistant, config_entry: ConfigEntry): +async def test_service_set_charge_start( + hass: HomeAssistant, config_entry: ConfigEntry, caplog: pytest.LogCaptureFixture +): """Test that service invokes renault_api with correct data.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -258,6 +260,7 @@ async def test_service_set_charge_start(hass: HomeAssistant, config_entry: Confi ) assert len(mock_action.mock_calls) == 1 assert mock_action.mock_calls[0][1] == () + assert f"'{DOMAIN}.{SERVICE_CHARGE_START}' service is deprecated" in caplog.text async def test_service_invalid_device_id(