diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index e080e2b5962..4c1376288f0 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -1,6 +1,7 @@ """Constants for the Renault component.""" from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_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 DOMAIN = "renault" @@ -13,6 +14,7 @@ DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ BINARY_SENSOR_DOMAIN, DEVICE_TRACKER_DOMAIN, + SELECT_DOMAIN, SENSOR_DOMAIN, ] diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py new file mode 100644 index 00000000000..fa9c491030d --- /dev/null +++ b/homeassistant/components/renault/select.py @@ -0,0 +1,101 @@ +"""Support for Renault sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, cast + +from renault_api.kamereon.models import KamereonVehicleBatteryStatusData + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +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_hub import RenaultHub + + +@dataclass +class RenaultSelectRequiredKeysMixin: + """Mixin for required keys.""" + + data_key: str + icon_lambda: Callable[[RenaultSelectEntity], str] + options: list[str] + + +@dataclass +class RenaultSelectEntityDescription( + SelectEntityDescription, RenaultEntityDescription, RenaultSelectRequiredKeysMixin +): + """Class describing Renault select entities.""" + + +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[RenaultSelectEntity] = [ + RenaultSelectEntity(vehicle, description) + for vehicle in proxy.vehicles.values() + for description in SENSOR_TYPES + if description.coordinator in vehicle.coordinators + ] + async_add_entities(entities) + + +class RenaultSelectEntity( + RenaultDataEntity[KamereonVehicleBatteryStatusData], SelectEntity +): + """Mixin for sensor specific attributes.""" + + entity_description: RenaultSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return cast(str, self.data) + + @property + def data(self) -> StateType: + """Return the state of this entity.""" + return self._get_data_attr(self.entity_description.data_key) + + @property + def icon(self) -> str | None: + """Icon handling.""" + return self.entity_description.icon_lambda(self) + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self.entity_description.options + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.vehicle.vehicle.set_charge_mode(option) + + +def _get_charge_mode_icon(entity: RenaultSelectEntity) -> str: + """Return the icon of this entity.""" + if entity.data == "schedule_mode": + return "mdi:calendar-clock" + return "mdi:calendar-remove" + + +SENSOR_TYPES: tuple[RenaultSelectEntityDescription, ...] = ( + RenaultSelectEntityDescription( + key="charge_mode", + coordinator="charge_mode", + data_key="chargeMode", + device_class=DEVICE_CLASS_CHARGE_MODE, + icon_lambda=_get_charge_mode_icon, + name="Charge Mode", + options=["always", "always_charging", "schedule_mode"], + ), +) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 62903702df0..b2161fc9adf 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -7,7 +7,6 @@ from typing import Callable, cast from renault_api.kamereon.enums import ChargeState, PlugState from renault_api.kamereon.models import ( KamereonVehicleBatteryStatusData, - KamereonVehicleChargeModeData, KamereonVehicleCockpitData, KamereonVehicleHvacStatusData, ) @@ -36,12 +35,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import ( - DEVICE_CLASS_CHARGE_MODE, - DEVICE_CLASS_CHARGE_STATE, - DEVICE_CLASS_PLUG_STATE, - DOMAIN, -) +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_hub import RenaultHub @@ -110,13 +104,6 @@ class RenaultSensor(RenaultDataEntity[T], SensorEntity): return self.entity_description.value_lambda(self) -def _get_charge_mode_icon(entity: RenaultSensor[T]) -> str: - """Return the icon of this entity.""" - if entity.data == "schedule_mode": - return "mdi:calendar-clock" - return "mdi:calendar-remove" - - def _get_charging_power(entity: RenaultSensor[T]) -> StateType: """Return the charging_power of this entity.""" if entity.vehicle.details.reports_charging_power_in_watts(): @@ -284,13 +271,4 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( native_unit_of_measurement=TEMP_CELSIUS, state_class=STATE_CLASS_MEASUREMENT, ), - RenaultSensorEntityDescription( - key="charge_mode", - coordinator="charge_mode", - data_key="chargeMode", - device_class=DEVICE_CLASS_CHARGE_MODE, - entity_class=RenaultSensor[KamereonVehicleChargeModeData], - icon_lambda=_get_charge_mode_icon, - name="Charge Mode", - ), ) diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index cbc94c61bf4..4ffc08587e3 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -14,6 +14,8 @@ from homeassistant.components.renault.const import ( DOMAIN, ) from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTIONS from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, @@ -45,6 +47,7 @@ from homeassistant.const import ( FIXED_ATTRIBUTES = ( ATTR_DEVICE_CLASS, + ATTR_OPTIONS, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT, ) @@ -54,7 +57,7 @@ DYNAMIC_ATTRIBUTES = ( ) ICON_FOR_EMPTY_VALUES = { - "sensor.charge_mode": "mdi:calendar-remove", + "select.charge_mode": "mdi:calendar-remove", "sensor.charge_state": "mdi:flash-off", "sensor.plug_state": "mdi:power-plug-off", } @@ -106,6 +109,16 @@ MOCK_VEHICLES = { }, ], DEVICE_TRACKER_DOMAIN: [], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "always", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-remove", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -143,13 +156,6 @@ MOCK_VEHICLES = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, - { - "entity_id": "sensor.charge_mode", - "unique_id": "vf1aaaaa555777999_charge_mode", - "result": "always", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, - ATTR_ICON: "mdi:calendar-remove", - }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777999_charge_state", @@ -248,6 +254,16 @@ MOCK_VEHICLES = { ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", } ], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "schedule_mode", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-clock", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -285,13 +301,6 @@ MOCK_VEHICLES = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, - { - "entity_id": "sensor.charge_mode", - "unique_id": "vf1aaaaa555777999_charge_mode", - "result": "schedule_mode", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, - ATTR_ICON: "mdi:calendar-clock", - }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777999_charge_state", @@ -382,6 +391,16 @@ MOCK_VEHICLES = { ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", } ], + SELECT_DOMAIN: [ + { + "entity_id": "select.charge_mode", + "unique_id": "vf1aaaaa555777123_charge_mode", + "result": "always", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, + ATTR_ICON: "mdi:calendar-remove", + ATTR_OPTIONS: ["always", "always_charging", "schedule_mode"], + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -419,13 +438,6 @@ MOCK_VEHICLES = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, - { - "entity_id": "sensor.charge_mode", - "unique_id": "vf1aaaaa555777123_charge_mode", - "result": "always", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CHARGE_MODE, - ATTR_ICON: "mdi:calendar-remove", - }, { "entity_id": "sensor.charge_state", "unique_id": "vf1aaaaa555777123_charge_state", @@ -515,6 +527,7 @@ MOCK_VEHICLES = { ATTR_LAST_UPDATE: "2020-02-18T16:58:38+00:00", } ], + SELECT_DOMAIN: [], SENSOR_DOMAIN: [ { "entity_id": "sensor.fuel_autonomy", diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py new file mode 100644 index 00000000000..113db099447 --- /dev/null +++ b/tests/components/renault/test_select.py @@ -0,0 +1,194 @@ +"""Tests for Renault selects.""" +from unittest.mock import patch + +import pytest +from renault_api.kamereon import exceptions, schemas + +from homeassistant.components.renault.renault_entities import ATTR_LAST_UPDATE +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTION, SERVICE_SELECT_OPTION +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + check_device_registry, + get_no_data_icon, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, +) +from .const import DYNAMIC_ATTRIBUTES, FIXED_ATTRIBUTES, MOCK_VEHICLES + +from tests.common import load_fixture, mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_selects(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) + 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[SELECT_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_select_empty(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) + 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[SELECT_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_select_errors(hass: HomeAssistant, vehicle_type: str): + """Test for Renault selects with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + 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[SELECT_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + for attr in FIXED_ATTRIBUTES: + assert state.attributes.get(attr) == expected_entity.get(attr) + # Check dynamic attributes: + assert state.attributes.get(ATTR_ICON) == get_no_data_icon(expected_entity) + assert ATTR_LAST_UPDATE not in state.attributes + + +async def test_select_access_denied(hass: HomeAssistant): + """Test for Renault selects with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, access_denied_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_select_not_supported(hass: HomeAssistant): + """Test for Renault selects with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_type = "zoe_40" + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [SELECT_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, not_supported_exception + ) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + + assert len(entity_registry.entities) == 0 + + +async def test_select_charge_mode(hass: HomeAssistant): + """Test that service invokes renault_api with correct data.""" + await setup_renault_integration_vehicle(hass, "zoe_40") + + data = { + ATTR_ENTITY_ID: "select.charge_mode", + ATTR_OPTION: "always", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_mode", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_mode.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + SELECT_DOMAIN, SERVICE_SELECT_OPTION, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == ("always",) diff --git a/tests/fixtures/renault/action.set_charge_mode.json b/tests/fixtures/renault/action.set_charge_mode.json new file mode 100644 index 00000000000..60fa5a19e74 --- /dev/null +++ b/tests/fixtures/renault/action.set_charge_mode.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "ChargeMode", + "id": "guid", + "attributes": { "action": "schedule_mode" } + } +} \ No newline at end of file