"""Support for Roborock select.""" import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging from typing import Any from roborock import B01Props, CleanTypeMapping from roborock.data import ( CleanPathPreferenceMapping, RoborockDockDustCollectionModeCode, RoborockEnum, WaterLevelMapping, ZeoDetergentType, ZeoDryingMode, ZeoMode, ZeoProgram, ZeoRinse, ZeoSoftenerType, ZeoSpin, ZeoTemperature, ) from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType from roborock.devices.traits.b01 import Q7PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.home import HomeTrait from roborock.devices.traits.v1.maps import MapsTrait from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MAP_SLEEP from .coordinator import ( RoborockB01Q7UpdateCoordinator, RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, ) from .entity import ( RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1, ) PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class RoborockSelectDescription(SelectEntityDescription): """Class to describe a Roborock select entity.""" api_command: RoborockCommand """The command that the select entity will send to the API.""" value_fn: Callable[[PropertiesApi], str | None] """Function to get the current value of the select entity.""" options_lambda: Callable[[PropertiesApi], list[str] | None] """Get all options or return None if not supported.""" parameter_lambda: Callable[[str, PropertiesApi], list[int]] """Function to get the parameters for the api command.""" is_dock_entity: bool = False """Whether this entity is for the dock.""" @dataclass(frozen=True, kw_only=True) class RoborockB01SelectDescription(SelectEntityDescription): """Class to describe a Roborock B01 select entity.""" api_fn: Callable[[Q7PropertiesApi, str], Awaitable[Any]] """Function to call the API.""" value_fn: Callable[[B01Props], str | None] """Function to get the current value of the select entity.""" options_lambda: Callable[[Q7PropertiesApi], list[str] | None] """Get all options or return None if not supported.""" @dataclass(frozen=True, kw_only=True) class RoborockSelectDescriptionA01(SelectEntityDescription): """Class to describe a Roborock A01 select entity.""" # The protocol that the select entity will send to the api. data_protocol: RoborockZeoProtocol # Enum class for the select entity enum_class: type[RoborockEnum] B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [ RoborockB01SelectDescription( key="water_flow", translation_key="water_flow", api_fn=lambda api, value: api.set_water_level( WaterLevelMapping.from_value(value) ), value_fn=lambda data: data.water.value if data.water else None, options_lambda=lambda _: [option.value for option in WaterLevelMapping], entity_category=EntityCategory.CONFIG, ), RoborockB01SelectDescription( key="cleaning_mode", translation_key="cleaning_mode", api_fn=lambda api, value: api.set_mode(CleanTypeMapping.from_value(value)), value_fn=lambda data: data.mode.value if data.mode else None, options_lambda=lambda _: list(CleanTypeMapping.keys()), entity_category=EntityCategory.CONFIG, ), RoborockB01SelectDescription( key="cleaning_route", translation_key="cleaning_route", api_fn=lambda api, value: api.set_clean_path_preference( CleanPathPreferenceMapping.from_value(value) ), value_fn=lambda data: data.clean_path_preference_name, options_lambda=lambda _: list(CleanPathPreferenceMapping.keys()), entity_category=EntityCategory.CONFIG, ), ] SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ RoborockSelectDescription( key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, value_fn=lambda api: api.status.water_mode_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( [mode.value for mode in api.status.water_mode_options] if api.status.water_mode_options else None ), parameter_lambda=lambda key, api: [ {v: k for k, v in api.status.water_mode_mapping.items()}[key] ], ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, value_fn=lambda api: api.status.mop_route_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( [mode.value for mode in api.status.mop_route_options] if api.status.mop_route_options else None ), parameter_lambda=lambda key, api: [ {v: k for k, v in api.status.mop_route_mapping.items()}[key] ], ), RoborockSelectDescription( key="dust_collection_mode", translation_key="dust_collection_mode", api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, value_fn=lambda api: ( mode.name if (mode := api.dust_collection_mode.mode) is not None else None # type: ignore[union-attr] ), entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( RoborockDockDustCollectionModeCode.keys() if api.dust_collection_mode is not None else None ), parameter_lambda=lambda key, _: [ RoborockDockDustCollectionModeCode.as_dict().get(key) ], is_dock_entity=True, ), ] A01_SELECT_DESCRIPTIONS: list[RoborockSelectDescriptionA01] = [ RoborockSelectDescriptionA01( key="program", data_protocol=RoborockZeoProtocol.PROGRAM, translation_key="program", entity_category=EntityCategory.CONFIG, enum_class=ZeoProgram, ), RoborockSelectDescriptionA01( key="mode", data_protocol=RoborockZeoProtocol.MODE, translation_key="mode", entity_category=EntityCategory.CONFIG, enum_class=ZeoMode, ), RoborockSelectDescriptionA01( key="temperature", data_protocol=RoborockZeoProtocol.TEMP, translation_key="temperature", entity_category=EntityCategory.CONFIG, enum_class=ZeoTemperature, ), RoborockSelectDescriptionA01( key="drying_mode", data_protocol=RoborockZeoProtocol.DRYING_MODE, translation_key="drying_mode", entity_category=EntityCategory.CONFIG, enum_class=ZeoDryingMode, ), RoborockSelectDescriptionA01( key="spin_level", data_protocol=RoborockZeoProtocol.SPIN_LEVEL, translation_key="spin_level", entity_category=EntityCategory.CONFIG, enum_class=ZeoSpin, ), RoborockSelectDescriptionA01( key="rinse_times", data_protocol=RoborockZeoProtocol.RINSE_TIMES, translation_key="rinse_times", entity_category=EntityCategory.CONFIG, enum_class=ZeoRinse, ), RoborockSelectDescriptionA01( key="detergent_type", data_protocol=RoborockZeoProtocol.DETERGENT_TYPE, translation_key="detergent_type", entity_category=EntityCategory.CONFIG, enum_class=ZeoDetergentType, ), RoborockSelectDescriptionA01( key="softener_type", data_protocol=RoborockZeoProtocol.SOFTENER_TYPE, translation_key="softener_type", entity_category=EntityCategory.CONFIG, enum_class=ZeoSoftenerType, ), ] async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock select platform.""" async_add_entities( RoborockSelectEntity(coordinator, description, options) for coordinator in config_entry.runtime_data.v1 for description in SELECT_DESCRIPTIONS if ( (options := description.options_lambda(coordinator.properties_api)) is not None ) ) async_add_entities( RoborockCurrentMapSelectEntity( f"selected_map_{coordinator.duid_slug}", coordinator, home_trait, map_trait ) for coordinator in config_entry.runtime_data.v1 if (home_trait := coordinator.properties_api.home) is not None if (map_trait := coordinator.properties_api.maps) is not None ) async_add_entities( RoborockB01SelectEntity(coordinator, description, options) for coordinator in config_entry.runtime_data.b01_q7 for description in B01_SELECT_DESCRIPTIONS if (options := description.options_lambda(coordinator.api)) is not None ) async_add_entities( RoborockSelectEntityA01(coordinator, description) for coordinator in config_entry.runtime_data.a01 for description in A01_SELECT_DESCRIPTIONS if description.data_protocol in coordinator.request_protocols ) async_add_entities( RoborockQ10CleanModeSelectEntity(coordinator) for coordinator in config_entry.runtime_data.b01_q10 ) class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity): """Select entity for Roborock B01 devices.""" entity_description: RoborockB01SelectDescription coordinator: RoborockB01Q7UpdateCoordinator def __init__( self, coordinator: RoborockB01Q7UpdateCoordinator, entity_description: RoborockB01SelectDescription, options: list[str], ) -> None: """Initialize the entity.""" self.entity_description = entity_description super().__init__( f"{entity_description.key}_{coordinator.duid_slug}", coordinator ) self._attr_options = options async def async_select_option(self, option: str) -> None: """Set the option.""" try: await self.entity_description.api_fn(self.coordinator.api, option) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed", translation_placeholders={ "command": self.entity_description.key, }, ) from err await self.coordinator.async_refresh() @property def current_option(self) -> str | None: """Return the current option.""" return self.entity_description.value_fn(self.coordinator.data) class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """A class to set options on a Roborock vacuum with fixed options.""" entity_description: RoborockSelectDescription def __init__( self, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSelectDescription, options: list[str], ) -> None: """Create a select entity.""" self.entity_description = entity_description super().__init__( f"{entity_description.key}_{coordinator.duid_slug}", coordinator, is_dock_entity=entity_description.is_dock_entity, ) self._attr_options = options async def async_select_option(self, option: str) -> None: """Set the option.""" await self.send( self.entity_description.api_command, self.entity_description.parameter_lambda( option, self.coordinator.properties_api ), ) @property def current_option(self) -> str | None: """Get the current status of the select entity from device props.""" return self.entity_description.value_fn(self.coordinator.properties_api) class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """A class to let you set the selected map on Roborock vacuum.""" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "selected_map" def __init__( self, unique_id: str, coordinator: RoborockDataUpdateCoordinator, home_trait: HomeTrait, maps_trait: MapsTrait, ) -> None: """Create a select entity to choose the current map.""" super().__init__(unique_id, coordinator) self._home_trait = home_trait self._maps_trait = maps_trait @property def _available_map_names(self) -> dict[int, str]: """Get the available maps by map id.""" return { map_id: map_.name or f"Map {map_id}" for map_id, map_ in (self._home_trait.home_map_info or {}).items() } async def async_select_option(self, option: str) -> None: """Set the option.""" for map_id, map_name in self._available_map_names.items(): if map_name == option: try: await self._maps_trait.set_current_map(map_id) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed", translation_placeholders={ "command": "load_multi_map", }, ) from err # We need to wait after updating the map # so that other commands will be executed correctly. await asyncio.sleep(MAP_SLEEP) try: await self._home_trait.refresh() except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="update_data_fail", ) from err break @property def options(self) -> list[str]: """Gets all of the names of rooms that we are currently aware of.""" return list(self._available_map_names.values()) @property def current_option(self) -> str | None: """Get the current status of the select entity from device_status.""" if current_map_info := self._home_trait.current_map_data: return current_map_info.name or f"Map {current_map_info.map_flag}" return None class RoborockSelectEntityA01(RoborockCoordinatedEntityA01, SelectEntity): """A class to let you set options on a Roborock A01 device.""" entity_description: RoborockSelectDescriptionA01 def __init__( self, coordinator: RoborockDataUpdateCoordinatorA01, entity_description: RoborockSelectDescriptionA01, ) -> None: """Create an A01 select entity.""" self.entity_description = entity_description super().__init__( f"{entity_description.key}_{coordinator.duid_slug}", coordinator, ) self._attr_options = list(entity_description.enum_class.keys()) async def async_select_option(self, option: str) -> None: """Set the option.""" # Get the protocol value for the selected option option_values = self.entity_description.enum_class.as_dict() if option not in option_values: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="select_option_failed", ) value = option_values[option] try: await self.coordinator.api.set_value( # type: ignore[attr-defined] self.entity_description.data_protocol, value, ) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed", translation_placeholders={ "command": self.entity_description.key, }, ) from err await self.coordinator.async_request_refresh() @property def current_option(self) -> str | None: """Get the current status of the select entity from coordinator data.""" if self.entity_description.data_protocol not in self.coordinator.data: return None current_value = self.coordinator.data[self.entity_description.data_protocol] if current_value is None: return None _LOGGER.debug( "current_value: %s for %s", current_value, self.entity_description.key, ) return str(current_value) class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity): """Select entity for Q10 cleaning mode.""" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "cleaning_mode" coordinator: RoborockB01Q10UpdateCoordinator def __init__( self, coordinator: RoborockB01Q10UpdateCoordinator, ) -> None: """Create a select entity for Q10 cleaning mode.""" super().__init__( f"cleaning_mode_{coordinator.duid_slug}", coordinator, ) async def async_added_to_hass(self) -> None: """Register trait listener for push-based status updates.""" await super().async_added_to_hass() self.async_on_remove( self.coordinator.api.status.add_update_listener(self.async_write_ha_state) ) @property def options(self) -> list[str]: """Return available cleaning modes.""" return [mode.value for mode in YXCleanType if mode != YXCleanType.UNKNOWN] @property def current_option(self) -> str | None: """Get the current cleaning mode.""" clean_mode = self.coordinator.api.status.clean_mode if clean_mode is None or clean_mode == YXCleanType.UNKNOWN: return None return clean_mode.value async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" try: mode = YXCleanType.from_value(option) except ValueError as err: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="select_option_failed", ) from err try: await self.coordinator.api.vacuum.set_clean_mode(mode) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed", translation_placeholders={"command": "cleaning_mode"}, ) from err