diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index b54637bb524..4c21201c37a 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -23,6 +23,13 @@ BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" + +REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" +REFRIGERATION_SUPERMODEREFRIGERATOR = ( + "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator" +) +REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled" + BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 33617f5472e..163c03b297c 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -21,5 +21,15 @@ "change_setting": { "service": "mdi:cog" } + }, + "entity": { + "switch": { + "refrigeration_dispenser": { + "default": "mdi:snowflake", + "state": { + "off": "mdi:snowflake-off" + } + } + } } } diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 8c7ef2eb11a..80e8e4b2d39 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -1,16 +1,18 @@ """Provides a switch for Home Connect.""" +from dataclasses import dataclass import logging from typing import Any from homeconnect.api import HomeConnectError -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .api import ConfigEntryAuth from .const import ( ATTR_VALUE, BSH_ACTIVE_PROGRAM, @@ -19,12 +21,39 @@ from .const import ( BSH_POWER_ON, BSH_POWER_STATE, DOMAIN, + REFRIGERATION_DISPENSER, + REFRIGERATION_SUPERMODEFREEZER, + REFRIGERATION_SUPERMODEREFRIGERATOR, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectDevice, HomeConnectEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class HomeConnectSwitchEntityDescription(SwitchEntityDescription): + """Switch entity description.""" + + on_key: str + + +SWITCHES: tuple[HomeConnectSwitchEntityDescription, ...] = ( + HomeConnectSwitchEntityDescription( + key="Supermode Freezer", + on_key=REFRIGERATION_SUPERMODEFREEZER, + ), + HomeConnectSwitchEntityDescription( + key="Supermode Refrigerator", + on_key=REFRIGERATION_SUPERMODEREFRIGERATOR, + ), + HomeConnectSwitchEntityDescription( + key="Dispenser Enabled", + on_key=REFRIGERATION_DISPENSER, + translation_key="refrigeration_dispenser", + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -35,18 +64,87 @@ async def async_setup_entry( def get_entities(): """Get a list of entities.""" entities = [] - hc_api = hass.data[DOMAIN][config_entry.entry_id] + hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("switch", []) entity_list = [HomeConnectProgramSwitch(**d) for d in entity_dicts] entity_list += [HomeConnectPowerSwitch(device_dict[CONF_DEVICE])] entity_list += [HomeConnectChildLockSwitch(device_dict[CONF_DEVICE])] - entities += entity_list + # Auto-discover entities + hc_device: HomeConnectDevice = device_dict[CONF_DEVICE] + entities.extend( + HomeConnectSwitch(device=hc_device, entity_description=description) + for description in SWITCHES + if description.on_key in hc_device.appliance.status + ) + entities.extend(entity_list) + return entities async_add_entities(await hass.async_add_executor_job(get_entities), True) +class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): + """Generic switch class for Home Connect Binary Settings.""" + + entity_description: HomeConnectSwitchEntityDescription + _attr_available: bool = False + + def __init__( + self, + device: HomeConnectDevice, + entity_description: HomeConnectSwitchEntityDescription, + ) -> None: + """Initialize the entity.""" + self.entity_description = entity_description + super().__init__(device=device, desc=entity_description.key) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on setting.""" + + _LOGGER.debug("Turning on %s", self.entity_description.key) + try: + await self.hass.async_add_executor_job( + self.device.appliance.set_setting, self.entity_description.on_key, True + ) + except HomeConnectError as err: + _LOGGER.error("Error while trying to turn on: %s", err) + self._attr_available = False + return + + self._attr_available = True + self.async_entity_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off setting.""" + + _LOGGER.debug("Turning off %s", self.entity_description.key) + try: + await self.hass.async_add_executor_job( + self.device.appliance.set_setting, self.entity_description.on_key, False + ) + except HomeConnectError as err: + _LOGGER.error("Error while trying to turn off: %s", err) + self._attr_available = False + return + + self._attr_available = True + self.async_entity_update() + + async def async_update(self) -> None: + """Update the switch's status.""" + + self._attr_is_on = self.device.appliance.status.get( + self.entity_description.on_key, {} + ).get(ATTR_VALUE) + self._attr_available = True + _LOGGER.debug( + "Updated %s, new state: %s", + self.entity_description.key, + self._attr_is_on, + ) + + class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Switch class for Home Connect.""" diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index eb6a5f5ff98..29d431419c6 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -111,5 +111,35 @@ } ] } + }, + "FridgeFreezer": { + "data": { + "settings": [ + { + "key": "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer", + "value": false, + "type": "Boolean", + "constraints": { + "access": "readWrite" + } + }, + { + "key": "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator", + "value": false, + "type": "Boolean", + "constraints": { + "access": "readWrite" + } + }, + { + "key": "Refrigeration.Common.Setting.Dispenser.Enabled", + "value": false, + "type": "Boolean", + "constraints": { + "access": "readWrite" + } + } + ] + } } } diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 02d9bcaa208..adfb4ff7a1d 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -9,6 +9,7 @@ import pytest from requests import HTTPError import requests_mock +from homeassistant.components.home_connect import SCAN_INTERVAL from homeassistant.components.home_connect.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -155,14 +156,14 @@ async def test_update_throttle( # First re-load after 1 minute is not blocked. assert await hass.config_entries.async_unload(config_entry.entry_id) assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(60) + freezer.tick(SCAN_INTERVAL.seconds + 0.1) assert await hass.config_entries.async_setup(config_entry.entry_id) assert get_appliances.call_count == get_appliances_call_count + 1 # Second re-load is blocked by Throttle. assert await hass.config_entries.async_unload(config_entry.entry_id) assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(59) + freezer.tick(SCAN_INTERVAL.seconds - 0.1) assert await hass.config_entries.async_setup(config_entry.entry_id) assert get_appliances.call_count == get_appliances_call_count + 1 diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index c6a7b384036..3ab550ad0af 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable, Generator from unittest.mock import MagicMock, Mock -from homeconnect.api import HomeConnectError +from homeconnect.api import HomeConnectAppliance, HomeConnectError import pytest from homeassistant.components.home_connect.const import ( @@ -13,10 +13,12 @@ from homeassistant.components.home_connect.const import ( BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STATE, + REFRIGERATION_SUPERMODEFREEZER, ) from homeassistant.components.switch import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -214,3 +216,117 @@ async def test_switch_exception_handling( DOMAIN, service, {"entity_id": entity_id}, blocking=True ) assert getattr(problematic_appliance, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ("entity_id", "status", "service", "state", "appliance"), + [ + ( + "switch.fridgefreezer_supermode_freezer", + {REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, + SERVICE_TURN_ON, + STATE_ON, + "FridgeFreezer", + ), + ( + "switch.fridgefreezer_supermode_freezer", + {REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, + SERVICE_TURN_OFF, + STATE_OFF, + "FridgeFreezer", + ), + ], + indirect=["appliance"], +) +async def test_ent_desc_switch_functionality( + entity_id: str, + status: dict, + service: str, + state: str, + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + appliance: Mock, + get_appliances: MagicMock, +) -> None: + """Test switch functionality - entity description setup.""" + appliance.status.update( + HomeConnectAppliance.json2dict( + load_json_object_fixture("home_connect/settings.json") + .get(appliance.name) + .get("data") + .get("settings") + ) + ) + get_appliances.return_value = [appliance] + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + appliance.status.update(status) + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.is_state(entity_id, state) + + +@pytest.mark.parametrize( + ("entity_id", "status", "service", "mock_attr", "problematic_appliance"), + [ + ( + "switch.fridgefreezer_supermode_freezer", + {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + SERVICE_TURN_ON, + "set_setting", + "FridgeFreezer", + ), + ( + "switch.fridgefreezer_supermode_freezer", + {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + SERVICE_TURN_OFF, + "set_setting", + "FridgeFreezer", + ), + ], + indirect=["problematic_appliance"], +) +async def test_ent_desc_switch_exception_handling( + entity_id: str, + status: dict, + service: str, + mock_attr: str, + bypass_throttle: Generator[None], + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + problematic_appliance: Mock, + get_appliances: MagicMock, +) -> None: + """Test switch exception handling - entity description setup.""" + problematic_appliance.status.update( + HomeConnectAppliance.json2dict( + load_json_object_fixture("home_connect/settings.json") + .get(problematic_appliance.name) + .get("data") + .get("settings") + ) + ) + get_appliances.return_value = [problematic_appliance] + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + # Assert that an exception is called. + with pytest.raises(HomeConnectError): + getattr(problematic_appliance, mock_attr)() + + problematic_appliance.status.update(status) + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert getattr(problematic_appliance, mock_attr).call_count == 2