"""Matter ModeSelect Cluster Support.""" from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from chip.clusters.Types import Nullable from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema DOOR_LOCK_OPERATING_MODE_MAP = { clusters.DoorLock.Enums.OperatingModeEnum.kNormal: "normal", clusters.DoorLock.Enums.OperatingModeEnum.kVacation: "vacation", clusters.DoorLock.Enums.OperatingModeEnum.kPrivacy: "privacy", clusters.DoorLock.Enums.OperatingModeEnum.kNoRemoteLockUnlock: ( "no_remote_lock_unlock" ), clusters.DoorLock.Enums.OperatingModeEnum.kPassage: "passage", } DOOR_LOCK_OPERATING_MODE_MAP_REVERSE = { v: k for k, v in DOOR_LOCK_OPERATING_MODE_MAP.items() } NUMBER_OF_RINSES_STATE_MAP = { clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNone: "off", clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNormal: "normal", clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kExtra: "extra", clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kMax: "max", clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kUnknownEnumValue: None, } NUMBER_OF_RINSES_STATE_MAP_REVERSE = { v: k for k, v in NUMBER_OF_RINSES_STATE_MAP.items() } PUMP_OPERATION_MODE_MAP = { clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kNormal: "normal", clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kMinimum: "minimum", clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kMaximum: "maximum", clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kLocal: "local", } PUMP_OPERATION_MODE_MAP_REVERSE = {v: k for k, v in PUMP_OPERATION_MODE_MAP.items()} type SelectCluster = ( clusters.ModeSelect | clusters.OvenMode | clusters.LaundryWasherMode | clusters.RefrigeratorAndTemperatureControlledCabinetMode | clusters.RvcRunMode | clusters.RvcCleanMode | clusters.DishwasherMode | clusters.EnergyEvseMode | clusters.DeviceEnergyManagementMode | clusters.WaterHeaterMode ) async def async_setup_entry( hass: HomeAssistant, config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter ModeSelect from Config Entry.""" matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.SELECT, async_add_entities) @dataclass(frozen=True, kw_only=True) class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescription): """Describe Matter select entities.""" @dataclass(frozen=True, kw_only=True) class MatterMapSelectEntityDescription(MatterSelectEntityDescription): """Describe Matter select entities for MatterMapSelectEntityDescription.""" device_to_ha: Callable[[int], str | None] ha_to_device: Callable[[str], int | None] # list attribute: the attribute descriptor to get the list # of values (= list of integers) list_attribute: type[ClusterAttributeDescriptor] @dataclass(frozen=True, kw_only=True) class MatterListSelectEntityDescription(MatterSelectEntityDescription): """Describe Matter select entities for MatterListSelectEntity.""" # list attribute: the attribute descriptor to get the list # of values (= list of strings) list_attribute: type[ClusterAttributeDescriptor] # command: a custom callback to create the command to send # to the device # the callback's argument will be the index of the selected # list value # if omitted the command will just be a write_attribute # command to the primary attribute command: Callable[[int], ClusterCommand] | None = None class MatterAttributeSelectEntity(MatterEntity, SelectEntity): """Representation of a select entity from Matter Attribute read/write.""" entity_description: MatterSelectEntityDescription async def async_select_option(self, option: str) -> None: """Change the selected mode.""" value_convert = self.entity_description.ha_to_device if TYPE_CHECKING: assert value_convert is not None await self.write_attribute( value=value_convert(option), ) @callback def _update_from_device(self) -> None: """Update from device.""" value: Nullable | int | None value = self.get_matter_attribute_value(self._entity_info.primary_attribute) value_convert = self.entity_description.device_to_ha if TYPE_CHECKING: assert value_convert is not None self._attr_current_option = value_convert(value) class MatterMapSelectEntity(MatterAttributeSelectEntity): """Matter select entity where options are from a State map.""" entity_description: MatterMapSelectEntityDescription @callback def _update_from_device(self) -> None: """Update from device.""" # the options can dynamically change based on the state of the device available_values = cast( list[int], self.get_matter_attribute_value(self.entity_description.list_attribute), ) # map available (int) values to string representation self._attr_options = [ mapped_value for value in available_values if (mapped_value := self.entity_description.device_to_ha(value)) ] # use base implementation from # MatterAttributeSelectEntity to set the current option super()._update_from_device() class MatterModeSelectEntity(MatterAttributeSelectEntity): """Representation of a select entity from Matter (Mode) Cluster attribute(s).""" async def async_select_option(self, option: str) -> None: """Change the selected mode.""" cluster: SelectCluster = self._endpoint.get_cluster( self._entity_info.primary_attribute.cluster_id ) # select the mode ID from the label string for mode in cluster.supportedModes: if mode.label != option: continue await self.send_device_command( cluster.Commands.ChangeToMode(newMode=mode.mode), ) break @callback def _update_from_device(self) -> None: """Update from device.""" # NOTE: cluster can be ModeSelect or a variant of that, # such as DishwasherMode. They all have the same characteristics. cluster: SelectCluster = self._endpoint.get_cluster( self._entity_info.primary_attribute.cluster_id ) modes = {mode.mode: mode.label for mode in cluster.supportedModes} self._attr_options = list(modes.values()) self._attr_current_option = modes.get(cluster.currentMode) # handle optional Description attribute as descriptive name for the mode if desc := getattr(cluster, "description", None): self._attr_name = desc class MatterDoorLockOperatingModeSelectEntity(MatterAttributeSelectEntity): """Representation of a Door Lock Operating Mode select entity. This entity dynamically filters available operating modes based on the device's `SupportedOperatingModes` bitmap attribute. In this bitmap, bit=0 indicates a supported mode and bit=1 indicates unsupported (inverted from typical conventions). If the bitmap is unavailable, only mandatory modes are included. The mapping from bitmap bits to operating mode values is defined by the Matter specification. """ entity_description: MatterMapSelectEntityDescription @callback def _update_from_device(self) -> None: """Update from device.""" # Get the bitmap of supported operating modes supported_modes_bitmap = self.get_matter_attribute_value( self.entity_description.list_attribute ) # Convert bitmap to list of supported mode values # NOTE: The Matter spec inverts the usual meaning: bit=0 means supported, # bit=1 means not supported, undefined bits must be 1. Mandatory modes are # bits 0 (Normal) and 3 (NoRemoteLockUnlock). num_mode_bits = supported_modes_bitmap.bit_length() supported_mode_values = [ bit_position for bit_position in range(num_mode_bits) if not supported_modes_bitmap & (1 << bit_position) ] # Map supported mode values to their string representations self._attr_options = [ mapped_value for mode_value in supported_mode_values if (mapped_value := self.entity_description.device_to_ha(mode_value)) ] # Use base implementation to set the current option super()._update_from_device() class MatterListSelectEntity(MatterEntity, SelectEntity): """Select entity from Matter list and selected item attributes.""" entity_description: MatterListSelectEntityDescription async def async_select_option(self, option: str) -> None: """Change the selected option.""" option_id = self._attr_options.index(option) if TYPE_CHECKING: assert option_id is not None if self.entity_description.command: # custom command defined to set the new value await self.send_device_command( self.entity_description.command(option_id), ) return # regular write attribute to set the new value await self.write_attribute( value=option_id, ) @callback def _update_from_device(self) -> None: """Update from device.""" list_values_raw = self.get_matter_attribute_value( self.entity_description.list_attribute ) if TYPE_CHECKING: assert list_values_raw is not None # Accept both list[str] and list[int], convert to str list_values = [str(v) for v in list_values_raw] self._attr_options = list_values current_option_idx: int = self.get_matter_attribute_value( self._entity_info.primary_attribute ) try: self._attr_current_option = list_values[current_option_idx] except IndexError: self._attr_current_option = None # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterModeSelect", entity_category=EntityCategory.CONFIG, translation_key="mode", ), entity_class=MatterModeSelectEntity, required_attributes=( clusters.ModeSelect.Attributes.CurrentMode, clusters.ModeSelect.Attributes.SupportedModes, ), # don't discover this entry if the supported modes list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterOvenMode", translation_key="mode", ), entity_class=MatterModeSelectEntity, required_attributes=( clusters.OvenMode.Attributes.CurrentMode, clusters.OvenMode.Attributes.SupportedModes, ), # don't discover this entry if the supported modes list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterLaundryWasherMode", translation_key="mode", ), entity_class=MatterModeSelectEntity, required_attributes=( clusters.LaundryWasherMode.Attributes.CurrentMode, clusters.LaundryWasherMode.Attributes.SupportedModes, ), # don't discover this entry if the supported modes list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterRefrigeratorAndTemperatureControlledCabinetMode", translation_key="mode", ), entity_class=MatterModeSelectEntity, required_attributes=( clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.CurrentMode, clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.SupportedModes, ), # don't discover this entry if the supported modes list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterRvcCleanMode", translation_key="clean_mode", ), entity_class=MatterModeSelectEntity, required_attributes=( clusters.RvcCleanMode.Attributes.CurrentMode, clusters.RvcCleanMode.Attributes.SupportedModes, ), # don't discover this entry if the supported modes list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterDishwasherMode", translation_key="mode", ), entity_class=MatterModeSelectEntity, required_attributes=( clusters.DishwasherMode.Attributes.CurrentMode, clusters.DishwasherMode.Attributes.SupportedModes, ), # don't discover this entry if the supported modes list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterEnergyEvseMode", translation_key="mode", ), entity_class=MatterModeSelectEntity, required_attributes=( clusters.EnergyEvseMode.Attributes.CurrentMode, clusters.EnergyEvseMode.Attributes.SupportedModes, ), # don't discover this entry if the supported modes list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterDeviceEnergyManagementMode", translation_key="device_energy_management_mode", ), entity_class=MatterModeSelectEntity, required_attributes=( clusters.DeviceEnergyManagementMode.Attributes.CurrentMode, clusters.DeviceEnergyManagementMode.Attributes.SupportedModes, ), # don't discover this entry if the supported modes list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterStartUpOnOff", entity_category=EntityCategory.CONFIG, translation_key="startup_on_off", options=["on", "off", "toggle", "previous"], device_to_ha={ 0: "off", 1: "on", 2: "toggle", None: "previous", }.get, ha_to_device={ "off": 0, "on": 1, "toggle": 2, "previous": None, }.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,), # allow None value for previous state allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="SmokeCOSmokeSensitivityLevel", entity_category=EntityCategory.CONFIG, translation_key="sensitivity_level", options=["high", "standard", "low"], device_to_ha={ 0: "high", 1: "standard", 2: "low", }.get, ha_to_device={ "high": 0, "standard": 1, "low": 2, }.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeSensitivityLevel,), ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="TrvTemperatureDisplayMode", entity_category=EntityCategory.CONFIG, translation_key="temperature_display_mode", options=["Celsius", "Fahrenheit"], device_to_ha={ 0: "Celsius", 1: "Fahrenheit", }.get, ha_to_device={ "Celsius": 0, "Fahrenheit": 1, }.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=( clusters.ThermostatUserInterfaceConfiguration.Attributes.TemperatureDisplayMode, ), ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterListSelectEntityDescription( key="TemperatureControlSelectedTemperatureLevel", translation_key="temperature_level", command=lambda selected_index: ( clusters.TemperatureControl.Commands.SetTemperature( targetTemperatureLevel=selected_index ) ), list_attribute=clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, ), entity_class=MatterListSelectEntity, required_attributes=( clusters.TemperatureControl.Attributes.SelectedTemperatureLevel, clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, ), # don't discover this entry if the supported levels list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterListSelectEntityDescription( key="LaundryWasherControlsSpinSpeed", translation_key="laundry_washer_spin_speed", list_attribute=clusters.LaundryWasherControls.Attributes.SpinSpeeds, ), entity_class=MatterListSelectEntity, required_attributes=( clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, clusters.LaundryWasherControls.Attributes.SpinSpeeds, ), # don't discover this entry if the spinspeeds list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterMapSelectEntityDescription( key="MatterLaundryWasherNumberOfRinses", translation_key="laundry_washer_number_of_rinses", list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses, device_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, ha_to_device=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, ), entity_class=MatterMapSelectEntity, required_attributes=( clusters.LaundryWasherControls.Attributes.NumberOfRinses, clusters.LaundryWasherControls.Attributes.SupportedRinses, ), # don't discover this entry if the supported rinses list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterListSelectEntityDescription( key="MicrowaveOvenControlSelectedWattIndex", translation_key="power_level", command=lambda selected_index: ( clusters.MicrowaveOvenControl.Commands.SetCookingParameters( wattSettingIndex=selected_index ) ), list_attribute=clusters.MicrowaveOvenControl.Attributes.SupportedWatts, ), entity_class=MatterListSelectEntity, required_attributes=( clusters.MicrowaveOvenControl.Attributes.SelectedWattIndex, clusters.MicrowaveOvenControl.Attributes.SupportedWatts, ), # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="DoorLockSoundVolume", entity_category=EntityCategory.CONFIG, translation_key="door_lock_sound_volume", options=["silent", "low", "medium", "high"], device_to_ha={ 0: "silent", 1: "low", 3: "medium", 2: "high", }.get, ha_to_device={ "silent": 0, "low": 1, "medium": 3, "high": 2, }.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.DoorLock.Attributes.SoundVolume,), ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="PumpConfigurationAndControlOperationMode", translation_key="pump_operation_mode", options=list(PUMP_OPERATION_MODE_MAP.values()), device_to_ha=PUMP_OPERATION_MODE_MAP.get, ha_to_device=PUMP_OPERATION_MODE_MAP_REVERSE.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=( clusters.PumpConfigurationAndControl.Attributes.OperationMode, ), ), # Keep the legacy vendor-specific select entities until HA 2026.11.0, # so existing users can migrate before we remove them in favor of the # generic number slider. MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["10 mm", "20 mm", "30 mm"], device_to_ha={ # CurrentSensitivityLevel=0 / highest 0: "10 mm", # CurrentSensitivityLevel=1 / medium 1: "20 mm", # CurrentSensitivityLevel=2 / lowest 2: "30 mm", }.get, ha_to_device={ "10 mm": 0, "20 mm": 1, "30 mm": 2, }.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=( clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel, ), vendor_id=(4447,), product_id=(8194,), allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["low", "standard", "high"], device_to_ha={ 0: "low", 1: "standard", 2: "high", }.get, ha_to_device={ "low": 0, "standard": 1, "high": 2, }.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=( clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel, ), vendor_id=(4447,), product_id=( 8197, 8195, ), allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="HeimanOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["low", "standard", "high"], device_to_ha={ 0: "low", 1: "standard", 2: "high", }.get, ha_to_device={ "low": 0, "standard": 1, "high": 2, }.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=( clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel, ), vendor_id=(4619,), product_id=(4097,), allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterMapSelectEntityDescription( key="DoorLockOperatingMode", translation_key="door_lock_operating_mode", list_attribute=clusters.DoorLock.Attributes.SupportedOperatingModes, device_to_ha=DOOR_LOCK_OPERATING_MODE_MAP.get, ha_to_device=DOOR_LOCK_OPERATING_MODE_MAP_REVERSE.get, entity_category=EntityCategory.CONFIG, ), entity_class=MatterDoorLockOperatingModeSelectEntity, required_attributes=( clusters.DoorLock.Attributes.OperatingMode, clusters.DoorLock.Attributes.SupportedOperatingModes, ), ), ]