Add select platform discovery schemas for the Matter LaundryWasherControls cluster (#136261)

This commit is contained in:
Ludovic BOUÉ 2025-01-29 14:17:00 +01:00 committed by GitHub
parent 9a687e7f94
commit 32829596eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 274 additions and 6 deletions

View File

@ -37,6 +37,9 @@
}
},
"select": {
"laundry_washer_spin_speed": {
"default": "mdi:reload"
},
"temperature_level": {
"default": "mdi:thermometer"
}

View File

@ -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,
),
),
]

View File

@ -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": {

View File

@ -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": [],

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'select.laundrywasher_number_of_rinses',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.laundrywasher_spin_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'select.laundrywasher_spin_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Off',
})
# ---
# name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -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"