"""Matter ModeSelect Cluster Support.""" from __future__ import annotations 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.config_entries import ConfigEntry 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 get_matter 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: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter ModeSelect from Config Entry.""" matter = get_matter(hass) 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): """Representation of a Matter select entity where the options are defined in 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 bitmap 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): """Representation of a select entity from Matter list and selected item Cluster attribute(s).""" 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, ), ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, translation_key="sensitivity_level", options=["10 mm", "20 mm", "30 mm"], device_to_ha={ 0: "10 mm", # 10 mm => CurrentSensitivityLevel=0 / highest sensitivity level 1: "20 mm", # 20 mm => CurrentSensitivityLevel=1 / medium sensitivity level 2: "30 mm", # 30 mm => CurrentSensitivityLevel=2 / lowest sensitivity level }.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,), ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, 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, ), ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="HeimanOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, 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,), ), 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_class=MatterDoorLockOperatingModeSelectEntity, required_attributes=( clusters.DoorLock.Attributes.OperatingMode, clusters.DoorLock.Attributes.SupportedOperatingModes, ), ), ]