diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 56cdf52c649..f060e37f0e4 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -17,7 +17,7 @@ from .const import BRANDS_CONF_MAP, CONF_BRAND, DOMAIN, REGIONS_CONF_MAP _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] type WhirlpoolConfigEntry = ConfigEntry[AppliancesManager] diff --git a/homeassistant/components/whirlpool/select.py b/homeassistant/components/whirlpool/select.py new file mode 100644 index 00000000000..8cbfddf71be --- /dev/null +++ b/homeassistant/components/whirlpool/select.py @@ -0,0 +1,91 @@ +"""The select platform for Whirlpool Appliances.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Final, override + +from whirlpool.appliance import Appliance + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .const import DOMAIN +from .entity import WhirlpoolEntity + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class WhirlpoolSelectDescription(SelectEntityDescription): + """Class describing Whirlpool select entities.""" + + value_fn: Callable[[Appliance], str | None] + set_fn: Callable[[Appliance, str], Awaitable[bool]] + + +REFRIGERATOR_DESCRIPTIONS: Final[tuple[WhirlpoolSelectDescription, ...]] = ( + WhirlpoolSelectDescription( + key="refrigerator_temperature", + translation_key="refrigerator_temperature", + options=["-4", "-2", "0", "3", "5"], + value_fn=lambda fridge: str(val) + if (val := fridge.get_offset_temp()) is not None + else None, + set_fn=lambda fridge, option: fridge.set_offset_temp(int(option)), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the select platform.""" + appliances_manager = config_entry.runtime_data + + async_add_entities( + [ + WhirlpoolSelectEntity(refrigerator, description) + for refrigerator in appliances_manager.refrigerators + for description in REFRIGERATOR_DESCRIPTIONS + ] + ) + + +class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity): + """Whirlpool select entity.""" + + def __init__( + self, appliance: Appliance, description: WhirlpoolSelectDescription + ) -> None: + """Initialize the select entity.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description: WhirlpoolSelectDescription = description + + @override + @property + def current_option(self) -> str | None: + """Retrieve currently selected option.""" + return self.entity_description.value_fn(self._appliance) + + @override + async def async_select_option(self, option: str) -> None: + """Set the selected option.""" + try: + WhirlpoolSelectEntity._check_service_request( + await self.entity_description.set_fn(self._appliance, option) + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index b1c0caf7c94..5ab70fc97cd 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -46,6 +46,11 @@ } }, "entity": { + "select": { + "refrigerator_temperature": { + "name": "Temperature" + } + }, "sensor": { "dryer_state": { "name": "[%key:component::whirlpool::entity::sensor::washer_state::name%]", @@ -211,6 +216,9 @@ "appliances_fetch_failed": { "message": "Failed to fetch appliances" }, + "invalid_value_set": { + "message": "Invalid value provided" + }, "request_failed": { "message": "Request failed" } diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 75ab793eeec..4aa47f3859c 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -4,7 +4,7 @@ from unittest import mock from unittest.mock import Mock import pytest -from whirlpool import aircon, appliancesmanager, auth, dryer, oven, washer +from whirlpool import aircon, appliancesmanager, auth, dryer, oven, refrigerator, washer from whirlpool.backendselector import Brand, Region from .const import MOCK_SAID1, MOCK_SAID2 @@ -55,6 +55,7 @@ def fixture_mock_appliances_manager_api( mock_dryer_api, mock_oven_single_cavity_api, mock_oven_dual_cavity_api, + mock_refrigerator_api, ): """Set up AppliancesManager fixture.""" with ( @@ -77,6 +78,7 @@ def fixture_mock_appliances_manager_api( mock_oven_single_cavity_api, mock_oven_dual_cavity_api, ] + mock_appliances_manager.return_value.refrigerators = [mock_refrigerator_api] yield mock_appliances_manager @@ -203,3 +205,15 @@ def mock_oven_dual_cavity_api(): mock_oven.get_temp.return_value = 180 mock_oven.get_target_temp.return_value = 200 return mock_oven + + +@pytest.fixture +def mock_refrigerator_api(): + """Get a mock of a refrigerator.""" + mock_refrigerator = Mock(spec=refrigerator.Refrigerator, said="said_refrigerator") + mock_refrigerator.name = "Beer fridge" + mock_refrigerator.appliance_info = Mock( + data_model="refrigerator", category="refrigerator", model_number="12345" + ) + mock_refrigerator.get_offset_temp.return_value = 0 + return mock_refrigerator diff --git a/tests/components/whirlpool/snapshots/test_select.ambr b/tests/components/whirlpool/snapshots/test_select.ambr new file mode 100644 index 00000000000..018fb88fa69 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_select.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_all_entities[select.beer_fridge_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '-4', + '-2', + '0', + '3', + '5', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.beer_fridge_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'refrigerator_temperature', + 'unique_id': 'said_refrigerator-refrigerator_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.beer_fridge_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Beer fridge Temperature', + 'options': list([ + '-4', + '-2', + '0', + '3', + '5', + ]), + }), + 'context': , + 'entity_id': 'select.beer_fridge_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 38367f52455..2ea3cad5c14 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -84,6 +84,7 @@ async def test_setup_no_appliances( mock_appliances_manager_api.return_value.washers = [] mock_appliances_manager_api.return_value.dryers = [] mock_appliances_manager_api.return_value.ovens = [] + mock_appliances_manager_api.return_value.refrigerators = [] await init_integration(hass) assert len(hass.states.async_all()) == 0 diff --git a/tests/components/whirlpool/test_select.py b/tests/components/whirlpool/test_select.py new file mode 100644 index 00000000000..7e8eb453490 --- /dev/null +++ b/tests/components/whirlpool/test_select.py @@ -0,0 +1,96 @@ +"""Test the Whirlpool select domain.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +async def test_all_entities( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.SELECT) + + +@pytest.mark.parametrize( + ( + "entity_id", + "mock_fixture", + "mock_getter_method_name", + "mock_setter_method_name", + "values", + ), + [ + ( + "select.beer_fridge_temperature", + "mock_refrigerator_api", + "get_offset_temp", + "set_offset_temp", + [(-4, "-4"), (-2, "-2"), (0, "0"), (3, "3"), (5, "5")], + ), + ], +) +async def test_select_entities( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + mock_getter_method_name: str, + mock_setter_method_name: str, + values: list[tuple[int, str]], + request: pytest.FixtureRequest, +) -> None: + """Test reading and setting select options.""" + await init_integration(hass) + mock_instance = request.getfixturevalue(mock_fixture) + + # Test reading current option + mock_getter_method = getattr(mock_instance, mock_getter_method_name) + for raw_value, expected_state in values: + mock_getter_method.return_value = raw_value + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + # Test changing option + mock_setter_method = getattr(mock_instance, mock_setter_method_name) + for raw_value, selected_option in values: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: selected_option}, + blocking=True, + ) + assert mock_setter_method.call_count == 1 + mock_setter_method.assert_called_with(raw_value) + mock_setter_method.reset_mock() + + +async def test_select_option_value_error( + hass: HomeAssistant, mock_refrigerator_api: MagicMock +) -> None: + """Test handling of ValueError exception when selecting an option.""" + await init_integration(hass) + mock_refrigerator_api.set_offset_temp.side_effect = ValueError + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.beer_fridge_temperature", + ATTR_OPTION: "something", + }, + blocking=True, + )