diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4b469fa85e4..b811a3c19d3 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any, cast from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from matter_server.common import custom_clusters from homeassistant.components.number import ( @@ -44,6 +47,23 @@ class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescrip """Describe Matter Number Input entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterRangeNumberEntityDescription( + NumberEntityDescription, MatterEntityDescription +): + """Describe Matter Number Input entities with min and max values.""" + + ha_to_native_value: Callable[[Any], Any] + + # attribute descriptors to get the min and max value + min_attribute: type[ClusterAttributeDescriptor] + max_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 + command: Callable[[int], ClusterCommand] + + class MatterNumber(MatterEntity, NumberEntity): """Representation of a Matter Attribute as a Number entity.""" @@ -67,6 +87,42 @@ class MatterNumber(MatterEntity, NumberEntity): self._attr_native_value = value +class MatterRangeNumber(MatterEntity, NumberEntity): + """Representation of a Matter Attribute as a Number entity with min and max values.""" + + entity_description: MatterRangeNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + send_value = self.entity_description.ha_to_native_value(value) + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.command(send_value), + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_native_value = value + self._attr_native_min_value = ( + cast( + int, + self.get_matter_attribute_value(self.entity_description.min_attribute), + ) + / 100 + ) + self._attr_native_max_value = ( + cast( + int, + self.get_matter_attribute_value(self.entity_description.max_attribute), + ) + / 100 + ) + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -213,4 +269,27 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterNumber, required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="TemperatureControlTemperatureSetpoint", + name=None, + translation_key="temperature_setpoint", + command=lambda value: clusters.TemperatureControl.Commands.SetTemperature( + targetTemperature=value + ), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + measurement_to_ha=lambda x: None if x is None else x / 100, + ha_to_native_value=lambda x: round(x * 100), + min_attribute=clusters.TemperatureControl.Attributes.MinTemperature, + max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature, + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.TemperatureControl.Attributes.TemperatureSetpoint, + clusters.TemperatureControl.Attributes.MinTemperature, + clusters.TemperatureControl.Attributes.MaxTemperature, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 35a9daa2370..d1367ba66e2 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -183,6 +183,9 @@ "temperature_offset": { "name": "Temperature offset" }, + "temperature_setpoint": { + "name": "Temperature setpoint" + }, "pir_occupied_to_unoccupied_delay": { "name": "Occupied to unoccupied delay" }, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 5ba0f275f8d..d71980c0613 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -1846,6 +1846,64 @@ 'state': '0.0', }) # --- +# name: test_numbers[oven][number.mock_oven_temperature_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 288.0, + 'min': 76.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_oven_temperature_setpoint', + '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': 'Temperature setpoint', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[oven][number.mock_oven_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Oven Temperature setpoint', + 'max': 288.0, + 'min': 76.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_oven_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '76.0', + }) +# --- # name: test_numbers[pump][number.mock_pump_on_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1903,3 +1961,177 @@ 'state': '0', }) # --- +# name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 0.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.laundrywasher_temperature_setpoint', + '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': 'Temperature setpoint', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Temperature setpoint', + 'max': 0.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.laundrywasher_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15.0, + 'min': -18.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.refrigerator_temperature_setpoint_2', + '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': 'Temperature setpoint (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-2-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Temperature setpoint (2)', + 'max': -15.0, + 'min': -18.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_temperature_setpoint_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 4.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.refrigerator_temperature_setpoint_3', + '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': 'Temperature setpoint (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-3-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Temperature setpoint (3)', + 'max': 4.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_temperature_setpoint_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index c94b92dbc46..d1ccc1a229b 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -2,6 +2,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 import custom_clusters from matter_server.common.errors import MatterError @@ -101,6 +102,44 @@ async def test_eve_weather_sensor_altitude( ) +@pytest.mark.parametrize("node_fixture", ["silabs_refrigerator"]) +async def test_temperature_control_temperature_setpoint( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test TemperatureSetpoint from TemperatureControl.""" + # TemperatureSetpoint + state = hass.states.get("number.refrigerator_temperature_setpoint_2") + assert state + assert state.state == "-18.0" + + set_node_attribute(matter_node, 2, 86, 0, -1600) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.refrigerator_temperature_setpoint_2") + assert state + assert state.state == "-16.0" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.refrigerator_temperature_setpoint_2", + "value": -17, + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=2, + command=clusters.TemperatureControl.Commands.SetTemperature( + targetTemperature=-1700 + ), + ) + + @pytest.mark.parametrize("node_fixture", ["dimmable_light"]) async def test_matter_exception_on_write_attribute( hass: HomeAssistant,