From 32829596ebce2ba87f7d0ff7f987a728795886b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 29 Jan 2025 14:17:00 +0100 Subject: [PATCH] Add select platform discovery schemas for the Matter LaundryWasherControls cluster (#136261) --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/select.py | 95 ++++++++++++++- homeassistant/components/matter/strings.json | 12 ++ .../fixtures/nodes/silabs_laundrywasher.json | 2 +- .../matter/snapshots/test_select.ambr | 114 ++++++++++++++++++ tests/components/matter/test_select.py | 54 +++++++++ 6 files changed, 274 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 4f3e532d877..f9217cabcc4 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -37,6 +37,9 @@ } }, "select": { + "laundry_washer_spin_speed": { + "default": "mdi:reload" + }, "temperature_level": { "default": "mdi:thermometer" } diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index b10f4e0e484..ab3e708d7a9 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -20,6 +20,17 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +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() +} + type SelectCluster = ( clusters.ModeSelect | clusters.OvenMode @@ -48,15 +59,27 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip """Describe Matter select entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterMapSelectEntityDescription(MatterSelectEntityDescription): + """Describe Matter select entities for MatterMapSelectEntityDescription.""" + + measurement_to_ha: Callable[[int], str | None] + ha_to_native_value: 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.""" - # command: a callback to create the command to send to the device - # the callback's argument will be the index of the selected list value - command: Callable[[int], ClusterCommand] # 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): @@ -84,6 +107,29 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): 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.measurement_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).""" @@ -125,8 +171,19 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" option_id = self._attr_options.index(option) - await self.send_device_command( - self.entity_description.command(option_id), + + 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 @@ -328,4 +385,32 @@ DISCOVERY_SCHEMAS = [ clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, ), ), + 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, + ), + ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterMapSelectEntityDescription( + key="MatterLaundryWasherNumberOfRinses", + translation_key="laundry_washer_number_of_rinses", + list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses, + measurement_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, + ha_to_native_value=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, + ), + entity_class=MatterMapSelectEntity, + required_attributes=( + clusters.LaundryWasherControls.Attributes.NumberOfRinses, + clusters.LaundryWasherControls.Attributes.SupportedRinses, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 73ce41937fd..f1a123c61be 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -205,6 +205,18 @@ }, "temperature_display_mode": { "name": "Temperature display mode" + }, + "laundry_washer_number_of_rinses": { + "name": "Number of rinses", + "state": { + "off": "[%key:common::state::off%]", + "normal": "Normal", + "extra": "Extra", + "max": "Max" + } + }, + "laundry_washer_spin_speed": { + "name": "Spin speed" } }, "sensor": { diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index a91584d7212..3b1ed0043de 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -656,7 +656,7 @@ "1/83/0": ["Off", "Low", "Medium", "High"], "1/83/1": 0, "1/83/2": 0, - "1/83/3": [1, 2], + "1/83/3": [0, 1], "1/83/65532": 3, "1/83/65533": 1, "1/83/65528": [], diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 9a2639ba7e1..e9aa169b4fd 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1620,6 +1620,120 @@ 'state': 'unknown', }) # --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'normal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_number_of_rinses', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Number of rinses', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'laundry_washer_number_of_rinses', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherNumberOfRinses-83-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Number of rinses', + 'options': list([ + 'off', + 'normal', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_number_of_rinses', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_spin_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Spin speed', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'laundry_washer_spin_speed', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-LaundryWasherControlsSpinSpeed-83-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Spin speed', + 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_spin_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Off', + }) +# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 3643aa83fca..2403b4b1623 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion @@ -144,3 +145,56 @@ async def test_list_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("select.laundrywasher_temperature_level") assert state.state == "unknown" + + # SpinSpeedCurrent + matter_client.write_attribute.reset_mock() + state = hass.states.get("select.laundrywasher_spin_speed") + assert state + assert state.state == "Off" + assert state.attributes["options"] == ["Off", "Low", "Medium", "High"] + set_node_attribute(matter_node, 1, 83, 1, 3) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "High" + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.laundrywasher_spin_speed", + "option": "High", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, + ), + value=3, + ) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(matter_node, 1, 83, 1, 253) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_map_select_entities( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test MatterMapSelectEntity entities are discovered and working from a laundrywasher fixture.""" + # NumberOfRinses + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state + assert state.state == "off" + assert state.attributes["options"] == ["off", "normal"] + set_node_attribute(matter_node, 1, 83, 2, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state.state == "normal"