diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index c27230c01d8..a9f48eea5ba 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -1,5 +1,6 @@ """Common callbacks for all Home Connect platforms.""" +from collections import defaultdict from collections.abc import Callable from functools import partial from typing import cast @@ -9,7 +10,32 @@ from aiohomeconnect.model import EventKey from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity + + +def _create_option_entities( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, + known_entity_unique_ids: dict[str, str], + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ], + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create the required option entities for the appliances.""" + option_entities_to_add = [ + entity + for entity in get_option_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ] + known_entity_unique_ids.update( + { + cast(str, entity.unique_id): appliance.info.ha_id + for entity in option_entities_to_add + } + ) + async_add_entities(option_entities_to_add) def _handle_paired_or_connected_appliance( @@ -18,6 +44,12 @@ def _handle_paired_or_connected_appliance( get_entities_for_appliance: Callable[ [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ] + | None, + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Handle a new paired appliance or an appliance that has been connected. @@ -34,6 +66,28 @@ def _handle_paired_or_connected_appliance( for entity in get_entities_for_appliance(entry, appliance) if entity.unique_id not in known_entity_unique_ids ] + if get_option_entities_for_appliance: + entities_to_add.extend( + entity + for entity in get_option_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ) + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback + ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id @@ -47,11 +101,17 @@ def _handle_paired_or_connected_appliance( def _handle_depaired_appliance( entry: HomeConnectConfigEntry, known_entity_unique_ids: dict[str, str], + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], ) -> None: """Handle a removed appliance.""" for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items(): if appliance_id not in entry.runtime_data.data: known_entity_unique_ids.pop(entity_unique_id, None) + if appliance_id in changed_options_listener_remove_callbacks: + for listener in changed_options_listener_remove_callbacks.pop( + appliance_id + ): + listener() def setup_home_connect_entry( @@ -60,13 +120,44 @@ def setup_home_connect_entry( [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], async_add_entities: AddConfigEntryEntitiesCallback, + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ] + | None = None, ) -> None: """Set up the callbacks for paired and depaired appliances.""" known_entity_unique_ids: dict[str, str] = {} + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]] = ( + defaultdict(list) + ) entities: list[HomeConnectEntity] = [] for appliance in entry.runtime_data.data.values(): entities_to_add = get_entities_for_appliance(entry, appliance) + if get_option_entities_for_appliance: + entities_to_add.extend(get_option_entities_for_appliance(entry, appliance)) + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + (appliance.info.ha_id, event_key), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback + ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id @@ -83,6 +174,8 @@ def setup_home_connect_entry( entry, known_entity_unique_ids, get_entities_for_appliance, + get_option_entities_for_appliance, + changed_options_listener_remove_callbacks, async_add_entities, ), ( @@ -93,7 +186,12 @@ def setup_home_connect_entry( ) entry.async_on_unload( entry.runtime_data.async_add_special_listener( - partial(_handle_depaired_appliance, entry, known_entity_unique_ids), + partial( + _handle_depaired_appliance, + entry, + known_entity_unique_ids, + changed_options_listener_remove_callbacks, + ), (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), ) ) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index ceedde7fe72..b5f0f711597 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -7,7 +7,7 @@ from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any +from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( @@ -17,6 +17,8 @@ from aiohomeconnect.model import ( EventType, GetSetting, HomeAppliance, + OptionKey, + ProgramKey, SettingKey, Status, StatusKey, @@ -28,7 +30,7 @@ from aiohomeconnect.model.error import ( HomeConnectRequestError, UnauthorizedError, ) -from aiohomeconnect.model.program import EnumerateProgram +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -53,6 +55,7 @@ class HomeConnectApplianceData: events: dict[EventKey, Event] info: HomeAppliance + options: dict[OptionKey, ProgramDefinitionOption] programs: list[EnumerateProgram] settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] @@ -61,6 +64,8 @@ class HomeConnectApplianceData: """Update data with data from other instance.""" self.events.update(other.events) self.info.connected = other.info.connected + self.options.clear() + self.options.update(other.options) self.programs.clear() self.programs.extend(other.programs) self.settings.update(other.settings) @@ -172,8 +177,9 @@ class HomeConnectCoordinator( settings = self.data[event_message_ha_id].settings events = self.data[event_message_ha_id].events for event in event_message.data.items: - if event.key in SettingKey: - setting_key = SettingKey(event.key) + event_key = event.key + if event_key in SettingKey: + setting_key = SettingKey(event_key) if setting_key in settings: settings[setting_key].value = event.value else: @@ -183,7 +189,16 @@ class HomeConnectCoordinator( value=event.value, ) else: - events[event.key] = event + if event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + await self.update_options( + event_message_ha_id, + event_key, + ProgramKey(cast(str, event.value)), + ) + events[event_key] = event self._call_event_listener(event_message) case EventType.EVENT: @@ -338,6 +353,7 @@ class HomeConnectCoordinator( programs = [] events = {} + options = {} if appliance.type in APPLIANCES_WITH_PROGRAMS: try: all_programs = await self.client.get_all_programs(appliance.ha_id) @@ -351,15 +367,17 @@ class HomeConnectCoordinator( ) else: programs.extend(all_programs.programs) + current_program_key = None + program_options = None for program, event_key in ( - ( - all_programs.active, - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - ), ( all_programs.selected, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), + ( + all_programs.active, + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), ): if program and program.key: events[event_key] = Event( @@ -370,10 +388,30 @@ class HomeConnectCoordinator( "", program.key, ) + current_program_key = program.key + program_options = program.options + if current_program_key: + options = await self.get_options_definitions( + appliance.ha_id, current_program_key + ) + for option in program_options or []: + option_event_key = EventKey(option.key) + events[option_event_key] = Event( + option_event_key, + option.key, + 0, + "", + "", + option.value, + option.name, + display_value=option.display_value, + unit=option.unit, + ) appliance_data = HomeConnectApplianceData( events=events, info=appliance, + options=options, programs=programs, settings=settings, status=status, @@ -383,3 +421,48 @@ class HomeConnectCoordinator( appliance_data = appliance_data_to_update return appliance_data + + async def get_options_definitions( + self, ha_id: str, program_key: ProgramKey + ) -> dict[OptionKey, ProgramDefinitionOption]: + """Get options with constraints for appliance.""" + return { + option.key: option + for option in ( + await self.client.get_available_program(ha_id, program_key=program_key) + ).options + or [] + } + + async def update_options( + self, ha_id: str, event_key: EventKey, program_key: ProgramKey + ) -> None: + """Update options for appliance.""" + options = self.data[ha_id].options + events = self.data[ha_id].events + options_to_notify = options.copy() + options.clear() + if program_key is not ProgramKey.UNKNOWN: + options.update(await self.get_options_definitions(ha_id, program_key)) + + for option in options.values(): + option_value = option.constraints.default if option.constraints else None + if option_value is not None: + option_event_key = EventKey(option.key) + events[option_event_key] = Event( + option_event_key, + option.key.value, + 0, + "", + "", + option_value, + option.name, + unit=option.unit, + ) + options_to_notify.update(options) + for option_key in options_to_notify: + for listener in self.context_listeners.get( + (ha_id, EventKey(option_key)), + [], + ): + listener() diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 8eb9d757f14..52eaaecace7 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,17 +1,22 @@ """Home Connect entity base class.""" from abc import abstractmethod +import contextlib import logging +from typing import cast -from aiohomeconnect.model import EventKey +from aiohomeconnect.model import EventKey, OptionKey +from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -60,3 +65,59 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): return ( self.appliance.info.connected and self._attr_available and super().available ) + + +class HomeConnectOptionEntity(HomeConnectEntity): + """Class for entities that represents program options.""" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.bsh_key in self.appliance.options + + @property + def option_value(self) -> str | int | float | bool | None: + """Return the state of the entity.""" + if event := self.appliance.events.get(EventKey(self.bsh_key)): + return event.value + return None + + async def async_set_option(self, value: str | float | bool) -> None: + """Set an option for the entity.""" + try: + # We try to set the active program option first, + # if it fails we try to set the selected program option + with contextlib.suppress(ActiveProgramNotSetError): + await self.coordinator.client.set_active_program_option( + self.appliance.info.ha_id, + option_key=self.bsh_key, + value=value, + ) + _LOGGER.debug( + "Updated %s for the active program, new state: %s", + self.entity_id, + self.state, + ) + return + + await self.coordinator.client.set_selected_program_option( + self.appliance.info.ha_id, + option_key=self.bsh_key, + value=value, + ) + _LOGGER.debug( + "Updated %s for the selected program, new state: %s", + self.entity_id, + self.state, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_option", + translation_placeholders=get_dict_from_home_connect_error(err), + ) from err + + @property + def bsh_key(self) -> OptionKey: + """Return the BSH key.""" + return cast(OptionKey, self.entity_description.key) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 6b604fc004e..651c00328b6 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -208,6 +208,39 @@ }, "door-assistant_freezer": { "default": "mdi:door" + }, + "silence_on_demand": { + "default": "mdi:volume-mute", + "state": { + "on": "mdi:volume-mute", + "off": "mdi:volume-high" + } + }, + "half_load": { + "default": "mdi:fraction-one-half" + }, + "hygiene_plus": { + "default": "mdi:silverware-clean" + }, + "eco_dry": { + "default": "mdi:sprout" + }, + "fast_pre_heat": { + "default": "mdi:fire" + }, + "i_dos_1_active": { + "default": "mdi:numeric-1-circle" + }, + "i_dos_2_active": { + "default": "mdi:numeric-2-circle" + } + }, + "time": { + "start_in_relative": { + "default": "mdi:progress-clock" + }, + "finish_in_relative": { + "default": "mdi:progress-clock" } } } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 26c4aa02372..63df33e5432 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -3,7 +3,7 @@ import logging from typing import cast -from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model import GetSetting, OptionKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( @@ -11,6 +11,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) +from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,11 +25,17 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +UNIT_MAP = { + "seconds": UnitOfTime.SECONDS, + "ml": UnitOfVolume.MILLILITERS, + "°C": UnitOfTemperature.CELSIUS, + "°F": UnitOfTemperature.FAHRENHEIT, +} NUMBERS = ( NumberEntityDescription( @@ -88,6 +95,32 @@ NUMBERS = ( ), ) +NUMBER_OPTIONS = ( + NumberEntityDescription( + key=OptionKey.BSH_COMMON_DURATION, + translation_key="duration", + ), + NumberEntityDescription( + key=OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + translation_key="finish_in_relative", + ), + NumberEntityDescription( + key=OptionKey.BSH_COMMON_START_IN_RELATIVE, + translation_key="start_in_relative", + ), + NumberEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY, + translation_key="fill_quantity", + device_class=NumberDeviceClass.VOLUME, + native_step=1, + ), + NumberEntityDescription( + key=OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + translation_key="setpoint_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -101,6 +134,18 @@ def _get_entities_for_appliance( ] +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of currently available option entities.""" + return [ + HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description) + for description in NUMBER_OPTIONS + if description.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -111,6 +156,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -184,3 +230,44 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): or not hasattr(self, "_attr_native_step") ): await self.async_fetch_constraints() + + +class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity): + """Number option class for Home Connect.""" + + async def async_set_native_value(self, value: float) -> None: + """Set the native value of the entity.""" + await self.async_set_option(value) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_native_value = cast(float | None, self.option_value) + option_definition = self.appliance.options.get(self.bsh_key) + if option_definition: + if option_definition.unit: + candidate_unit = UNIT_MAP.get( + option_definition.unit, option_definition.unit + ) + if ( + not hasattr(self, "_attr_native_unit_of_measurement") + or candidate_unit != self._attr_native_unit_of_measurement + ): + self._attr_native_unit_of_measurement = candidate_unit + self.__dict__.pop("unit_of_measurement", None) + option_constraints = option_definition.constraints + if option_constraints: + if ( + not hasattr(self, "_attr_native_min_value") + or self._attr_native_min_value != option_constraints.min + ) and option_constraints.min: + self._attr_native_min_value = option_constraints.min + if ( + not hasattr(self, "_attr_native_max_value") + or self._attr_native_max_value != option_constraints.max + ) and option_constraints.max: + self._attr_native_max_value = option_constraints.max + if ( + not hasattr(self, "_attr_native_step") + or self._attr_native_step != option_constraints.step_size + ) and option_constraints.step_size: + self._attr_native_step = option_constraints.step_size diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index bc281e3d928..f5298056080 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution @@ -17,17 +17,32 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + BEAN_AMOUNT_OPTIONS, + BEAN_CONTAINER_OPTIONS, + CLEANING_MODE_OPTIONS, + COFFEE_MILK_RATIO_OPTIONS, + COFFEE_TEMPERATURE_OPTIONS, DOMAIN, + DRYING_TARGET_OPTIONS, + FLOW_RATE_OPTIONS, + HOT_WATER_TEMPERATURE_OPTIONS, + INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, + REFERENCE_MAP_ID_OPTIONS, + SPIN_SPEED_OPTIONS, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, + VARIO_PERFECT_OPTIONS, + VENTING_LEVEL_OPTIONS, + WARMING_LEVEL_OPTIONS, ) from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error @@ -44,6 +59,16 @@ class HomeConnectProgramSelectEntityDescription( error_translation_key: str +@dataclass(frozen=True, kw_only=True) +class HomeConnectSelectOptionEntityDescription( + SelectEntityDescription, +): + """Entity Description class for options that have enumeration values.""" + + translation_key_values: dict[str, str] + values_translation_key: dict[str, str] + + PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( HomeConnectProgramSelectEntityDescription( key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, @@ -65,6 +90,159 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( ), ) +PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, + translation_key="reference_map_id", + options=list(REFERENCE_MAP_ID_OPTIONS.keys()), + translation_key_values=REFERENCE_MAP_ID_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in REFERENCE_MAP_ID_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + translation_key="reference_map_id", + options=list(CLEANING_MODE_OPTIONS.keys()), + translation_key_values=CLEANING_MODE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in CLEANING_MODE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, + translation_key="bean_amount", + options=list(BEAN_AMOUNT_OPTIONS.keys()), + translation_key_values=BEAN_AMOUNT_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in BEAN_AMOUNT_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, + translation_key="coffee_temperature", + options=list(COFFEE_TEMPERATURE_OPTIONS.keys()), + translation_key_values=COFFEE_TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, + translation_key="bean_container", + options=list(BEAN_CONTAINER_OPTIONS.keys()), + translation_key_values=BEAN_CONTAINER_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in BEAN_CONTAINER_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, + translation_key="flow_rate", + options=list(FLOW_RATE_OPTIONS.keys()), + translation_key_values=FLOW_RATE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in FLOW_RATE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, + translation_key="coffee_milk_ratio", + options=list(COFFEE_MILK_RATIO_OPTIONS.keys()), + translation_key_values=COFFEE_MILK_RATIO_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in FLOW_RATE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, + translation_key="hot_water_temperature", + options=list(HOT_WATER_TEMPERATURE_OPTIONS.keys()), + translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, + translation_key="drying_target", + options=list(DRYING_TARGET_OPTIONS.keys()), + translation_key_values=DRYING_TARGET_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in DRYING_TARGET_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, + translation_key="venting_level", + options=list(VENTING_LEVEL_OPTIONS.keys()), + translation_key_values=VENTING_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in VENTING_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, + translation_key="intensive_level", + options=list(INTENSIVE_LEVEL_OPTIONS.keys()), + translation_key_values=INTENSIVE_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_OVEN_WARMING_LEVEL, + translation_key="warming_level", + options=list(WARMING_LEVEL_OPTIONS.keys()), + translation_key_values=WARMING_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in WARMING_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + translation_key="washer_temperature", + options=list(TEMPERATURE_OPTIONS.keys()), + translation_key_values=TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, + translation_key="spin_speed", + options=list(SPIN_SPEED_OPTIONS.keys()), + translation_key_values=SPIN_SPEED_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in SPIN_SPEED_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, + translation_key="vario_perfect", + options=list(VARIO_PERFECT_OPTIONS.keys()), + translation_key_values=VARIO_PERFECT_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in VARIO_PERFECT_OPTIONS.items() + }, + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -81,6 +259,18 @@ def _get_entities_for_appliance( ) +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of entities.""" + return [ + HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc) + for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS + if desc.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -91,6 +281,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -148,3 +339,53 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err + + +class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): + """Select option class for Home Connect.""" + + entity_description: HomeConnectSelectOptionEntityDescription + _original_option_keys: set[str | None] + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectSelectOptionEntityDescription, + ) -> None: + """Initialize the entity.""" + self._original_option_keys = set(desc.values_translation_key.keys()) + super().__init__( + coordinator, + appliance, + desc, + ) + + async def async_select_option(self, option: str) -> None: + """Select new option.""" + await self.async_set_option( + self.entity_description.translation_key_values[option] + ) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_current_option = ( + self.entity_description.values_translation_key.get( + cast(str, self.option_value), None + ) + if self.option_value is not None + else None + ) + if ( + (option_definition := self.appliance.options.get(self.bsh_key)) + and (option_constraints := option_definition.constraints) + and option_constraints.allowed_values + and self._original_option_keys != set(option_constraints.allowed_values) + ): + self._original_option_keys = set(option_constraints.allowed_values) + self._attr_options = [ + self.entity_description.values_translation_key[option] + for option in self._original_option_keys + if option is not None + ] + self.__dict__.pop("options", None) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index d9f45c8c31d..88dd017e7d9 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -56,12 +56,6 @@ BSH_PROGRAM_SENSORS = ( "WasherDryer", ), ), - HomeConnectSensorEntityDescription( - key=EventKey.BSH_COMMON_OPTION_DURATION, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - appliance_types=("Oven",), - ), HomeConnectSensorEntityDescription( key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 3ac9f90ba81..8a4dd68530f 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -98,6 +98,9 @@ }, "required_program_or_one_option_at_least": { "message": "A program or at least one of the possible options for a program should be specified" + }, + "set_option": { + "message": "Error setting the option for the program: {error}" } }, "issues": { @@ -859,6 +862,21 @@ }, "washer_i_dos_2_base_level": { "name": "i-Dos 2 base level" + }, + "duration": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_duration::name%]" + }, + "start_in_relative": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]" + }, + "finish_in_relative": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]" + }, + "fill_quantity": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_fill_quantity::name%]" + }, + "setpoint_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]" } }, "select": { @@ -1179,6 +1197,200 @@ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]", "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } + }, + "reference_map_id": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]" + } + }, + "cleaning_mode": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_cleaning_mode::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]" + } + }, + "bean_amount": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_mild%]", + "consumer_products_coffee_maker_enum_type_bean_amount_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild%]", + "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_normal": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal%]", + "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_extra_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot%]", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground%]" + } + }, + "coffee_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_temperature::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_88_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_90_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_92_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_94_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_95_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_96_c%]" + } + }, + "bean_container": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]", + "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]" + } + }, + "flow_rate": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_flow_rate_normal": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_normal%]", + "consumer_products_coffee_maker_enum_type_flow_rate_intense": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense%]", + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense_plus%]" + } + }, + "coffee_milk_ratio": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_milk_ratio::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent%]" + } + }, + "hot_water_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_hot_water_temperature::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_max%]" + } + }, + "drying_target": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_dryer_option_drying_target::name%]", + "state": { + "laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]", + "laundry_care_dryer_enum_type_drying_target_gentle_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_gentle_dry%]", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry%]", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus%]", + "laundry_care_dryer_enum_type_drying_target_extra_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_extra_dry%]" + } + }, + "venting_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", + "state": { + "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", + "cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]", + "cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]", + "cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]", + "cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]", + "cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]" + } + }, + "intensive_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]", + "state": { + "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]" + } + }, + "warming_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", + "state": { + "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + } + }, + "washer_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]", + "state": { + "laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]", + "laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]", + "laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]", + "laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]", + "laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]", + "laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]", + "laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]", + "laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]", + "laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]", + "laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]", + "laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]", + "laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]", + "laundry_care_washer_enum_type_temperature_ul_extra_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_extra_hot%]" + } + }, + "spin_speed": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", + "state": { + "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]", + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + } + }, + "vario_perfect": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", + "state": { + "laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]", + "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", + "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]" + } } }, "sensor": { @@ -1365,6 +1577,45 @@ }, "door_assistant_freezer": { "name": "Freezer door assistant" + }, + "multiple_beverages": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]" + }, + "intensiv_zone": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]" + }, + "brilliance_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_brilliance_dry::name%]" + }, + "vario_speed_plus": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]" + }, + "silence_on_demand": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]" + }, + "half_load": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_half_load::name%]" + }, + "extra_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_extra_dry::name%]" + }, + "hygiene_plus": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]" + }, + "eco_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_eco_dry::name%]" + }, + "zeolite_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]" + }, + "fast_pre_heat": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_fast_pre_heat::name%]" + }, + "i_dos1_active": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]" + }, + "i_dos2_active": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]" } }, "time": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 7dc375f430d..d5a92eef2a4 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -3,7 +3,7 @@ import logging from typing import Any, cast -from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import EnumerateProgram @@ -37,7 +37,7 @@ from .coordinator import ( HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -100,6 +100,61 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( translation_key="power", ) +SWITCH_OPTIONS = ( + SwitchEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES, + translation_key="multiple_beverages", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE, + translation_key="intensiv_zone", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY, + translation_key="brilliance_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS, + translation_key="vario_speed_plus", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND, + translation_key="silence_on_demand", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + translation_key="half_load", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, + translation_key="extra_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + translation_key="hygiene_plus", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_ECO_DRY, + translation_key="eco_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY, + translation_key="zeolite_dry", + ), + SwitchEntityDescription( + key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT, + translation_key="fast_pre_heat", + ), + SwitchEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + translation_key="i_dos1_active", + ), + SwitchEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE, + translation_key="i_dos2_active", + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -123,10 +178,21 @@ def _get_entities_for_appliance( for description in SWITCHES if description.key in appliance.settings ) - return entities +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of currently available option entities.""" + return [ + HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description) + for description in SWITCH_OPTIONS + if description.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -137,6 +203,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -403,3 +470,19 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None + + +class HomeConnectSwitchOptionEntity(HomeConnectOptionEntity, SwitchEntity): + """Switch option class for Home Connect.""" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the option.""" + await self.async_set_option(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the option.""" + await self.async_set_option(False) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_is_on = cast(bool | None, self.option_value) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 7b74c2290c3..e0d60dc8614 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -23,6 +23,8 @@ from aiohomeconnect.model import ( HomeAppliance, Option, Program, + ProgramDefinition, + ProgramKey, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -339,6 +341,29 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.add_events = add_events + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + async def stream_all_events() -> AsyncGenerator[EventMessage]: """Mock stream_all_events.""" while True: @@ -380,6 +405,17 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) mock.put_command = AsyncMock() + mock.get_available_program = AsyncMock( + return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) + ) + mock.get_active_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.get_selected_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.set_active_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) + mock.set_selected_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) mock.side_effect = mock return mock @@ -420,6 +456,11 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_status = AsyncMock(side_effect=exception) mock.get_all_programs = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) + mock.get_available_program = AsyncMock(side_effect=exception) + mock.get_active_program_options = AsyncMock(side_effect=exception) + mock.get_selected_program_options = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) return mock diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index a357d8fb43e..8f649e5790b 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -124,6 +124,11 @@ "key": "BSH.Common.Setting.ChildLock", "value": false, "type": "Boolean" + }, + { + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "value": 0, + "type": "Integer" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3c73a32d95..512da8bd970 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -272,6 +272,7 @@ 'settings': dict({ 'BSH.Common.Setting.ChildLock': False, 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'LaundryCare.Washer.Setting.IDos2BaseLevel': 0, }), 'status': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py new file mode 100644 index 00000000000..272fc21ba62 --- /dev/null +++ b/tests/components/home_connect/test_entity.py @@ -0,0 +1,299 @@ +"""Tests for Home Connect entity base classes.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, + Event, + EventKey, + EventMessage, + EventType, + Option, + OptionKey, + Program, + ProgramDefinition, + ProgramKey, +) +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] + + +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "option_entity_id", + "options_state_stage_1", + "options_availability_stage_2", + "option_without_default", + "option_without_constraints", + ), + [ + ( + "Dishwasher", + { + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: "switch.dishwasher_silence_on_demand", + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: "switch.dishwasher_eco_dry", + }, + [(STATE_ON, True), (STATE_OFF, False), (None, None)], + [False, True, True], + ( + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + "switch.dishwasher_hygiene_plus", + ), + (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + option_entity_id: dict[OptionKey, str], + options_state_stage_1: list[tuple[str, bool | None]], + options_availability_stage_2: list[bool], + option_without_default: tuple[OptionKey, str], + option_without_constraints: tuple[OptionKey, str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + original_get_all_programs_mock = client.get_all_programs.side_effect + options_values = [ + Option( + option_key, + value, + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ] + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + if ha_id != appliance_ha_id: + return await original_get_all_programs_mock(ha_id) + + array_of_programs: ArrayOfPrograms = await original_get_all_programs_mock(ha_id) + return ArrayOfPrograms( + **( + { + "programs": array_of_programs.programs, + array_of_programs_program_arg: Program( + array_of_programs.programs[0].key, options=options_values + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id, (state, _) in zip( + option_entity_id.values(), options_state_stage_1, strict=True + ): + if state is not None: + assert hass.states.is_state(entity_id, state) + else: + assert not hass.states.get(entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + *[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, available in zip( + option_entity_id.keys(), + options_availability_stage_2, + strict=True, + ) + if available + ], + ProgramDefinitionOption( + option_without_default[0], + "Boolean", + constraints=ProgramDefinitionConstraints(), + ), + ProgramDefinitionOption( + option_without_constraints[0], + "Boolean", + ), + ], + ) + ) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + # Verify default values + # Every time the program is updated, the available options should use the default value if existing + for entity_id, available in zip( + option_entity_id.values(), options_availability_stage_2, strict=True + ): + assert hass.states.is_state( + entity_id, STATE_OFF if available else STATE_UNAVAILABLE + ) + for _, entity_id in (option_without_default, option_without_constraints): + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + + +@pytest.mark.parametrize( + ( + "set_active_program_option_side_effect", + "set_selected_program_option_side_effect", + ), + [ + ( + ActiveProgramNotSetError("error.key"), + SelectedProgramNotSetError("error.key"), + ), + ( + HomeConnectError(), + None, + ), + ( + ActiveProgramNotSetError("error.key"), + HomeConnectError(), + ), + ], +) +async def test_option_entity_functionality_exception( + set_active_program_option_side_effect: HomeConnectError | None, + set_selected_program_option_side_effect: HomeConnectError | None, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the option entity handles exceptions correctly.""" + entity_id = "switch.washer_i_dos_1_active" + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + if set_active_program_option_side_effect: + client.set_active_program_option = AsyncMock( + side_effect=set_active_program_option_side_effect + ) + if set_selected_program_option_side_effect: + client.set_selected_program_option = AsyncMock( + side_effect=set_selected_program_option_side_effect + ) + + with pytest.raises(HomeAssistantError, match=r"Error.*setting.*option.*"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index edab86cf819..214dcb6137c 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -7,17 +7,34 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfSettings, + Event, + EventKey, EventMessage, EventType, GetSetting, + OptionKey, + ProgramDefinition, + ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, ATTR_VALUE as SERVICE_ATTR_VALUE, DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, @@ -51,7 +68,6 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) async def test_paired_depaired_devices_flow( appliance_ha_id: str, hass: HomeAssistant, @@ -63,6 +79,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + "Integer", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -369,3 +396,135 @@ async def test_number_entity_error( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + [ + ( + "Oven", + "number.oven_setpoint_temperature", + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + 50, + 260, + 1, + "°C", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + min: int, + max: int, + step_size: int, + unit: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + unit=unit, + ) + ] + ), + ), + ] + ) + + called_mock = AsyncMock(side_effect=set_program_option_side_effect) + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + setattr(client, called_mock_method, called_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Double", + unit=unit, + constraints=ProgramDefinitionConstraints( + min=min, + max=max, + step_size=step_size, + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit + assert entity_state.attributes[ATTR_MIN] == min + assert entity_state.attributes[ATTR_MAX] == max + assert entity_state.attributes[ATTR_STEP] == step_size + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, SERVICE_ATTR_VALUE: 80}, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": 80, + } + assert hass.states.is_state(entity_id, "80.0") diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index a1e6fafd768..917c092136e 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,7 +1,7 @@ """Tests for home_connect select entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, @@ -10,13 +10,21 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + OptionKey, + ProgramDefinition, ProgramKey, ) -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) from aiohomeconnect.model.program import ( EnumerateProgram, EnumerateProgramConstraints, Execution, + ProgramDefinitionConstraints, + ProgramDefinitionOption, ) import pytest @@ -70,6 +78,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + "Enumeration", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -413,3 +432,132 @@ async def test_select_exception_handling( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "allowed_values", "expected_options"), + [ + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + None, + { + "laundry_care_washer_enum_type_temperature_cold", + "laundry_care_washer_enum_type_temperature_g_c_20", + "laundry_care_washer_enum_type_temperature_g_c_30", + "laundry_care_washer_enum_type_temperature_g_c_40", + "laundry_care_washer_enum_type_temperature_g_c_50", + "laundry_care_washer_enum_type_temperature_g_c_60", + "laundry_care_washer_enum_type_temperature_g_c_70", + "laundry_care_washer_enum_type_temperature_g_c_80", + "laundry_care_washer_enum_type_temperature_g_c_90", + "laundry_care_washer_enum_type_temperature_ul_cold", + "laundry_care_washer_enum_type_temperature_ul_warm", + "laundry_care_washer_enum_type_temperature_ul_hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot", + }, + ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "LaundryCare.Washer.EnumType.Temperature.UlCold", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + "LaundryCare.Washer.EnumType.Temperature.UlHot", + "LaundryCare.Washer.EnumType.Temperature.UlExtraHot", + ], + { + "laundry_care_washer_enum_type_temperature_ul_cold", + "laundry_care_washer_enum_type_temperature_ul_warm", + "laundry_care_washer_enum_type_temperature_ul_hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot", + }, + ), + ], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + allowed_values: list[str | None] | None, + expected_options: set[str], + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Enumeration", + constraints=ProgramDefinitionConstraints( + allowed_values=allowed_values + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": "LaundryCare.Washer.EnumType.Temperature.UlWarm", + } + assert hass.states.is_state( + entity_id, "laundry_care_washer_enum_type_temperature_ul_warm" + ) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index d4e0f999197..1b38809dc05 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -5,17 +5,26 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, ArrayOfSettings, Event, EventKey, EventMessage, + EventType, GetSetting, + OptionKey, + ProgramDefinition, ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError -from aiohomeconnect.model.event import ArrayOfEvents, EventType -from aiohomeconnect.model.program import ArrayOfPrograms, EnumerateProgram +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from aiohomeconnect.model.setting import SettingConstraints import pytest @@ -81,6 +90,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -840,3 +860,95 @@ async def test_create_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "appliance_ha_id"), + [ + ( + "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "Dishwasher", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, options=[ProgramDefinitionOption(option_key, "Boolean")] + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity_id) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": False, + } + assert hass.states.is_state(entity_id, STATE_OFF) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": True, + } + assert hass.states.is_state(entity_id, STATE_ON)