Matter TemperatureControl (#145706)

* TemperatureControl

* Add tests

* Commands.SetTemperature

* Update homeassistant/components/matter/number.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update number.py

* Update number.py

* Update number.py

* Update homeassistant/components/matter/number.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Refactor MatterRangeNumber to streamline command handling in async_set_native_value

* testing requested changes

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Ludovic BOUÉ 2025-06-27 19:41:39 +02:00 committed by GitHub
parent b630fb0520
commit e3ba1f34ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 353 additions and 0 deletions

View File

@ -2,9 +2,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, cast
from chip.clusters import Objects as clusters from chip.clusters import Objects as clusters
from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
from matter_server.common import custom_clusters from matter_server.common import custom_clusters
from homeassistant.components.number import ( from homeassistant.components.number import (
@ -44,6 +47,23 @@ class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescrip
"""Describe Matter Number Input entities.""" """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): class MatterNumber(MatterEntity, NumberEntity):
"""Representation of a Matter Attribute as a Number entity.""" """Representation of a Matter Attribute as a Number entity."""
@ -67,6 +87,42 @@ class MatterNumber(MatterEntity, NumberEntity):
self._attr_native_value = value 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 schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [ DISCOVERY_SCHEMAS = [
MatterDiscoverySchema( MatterDiscoverySchema(
@ -213,4 +269,27 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterNumber, entity_class=MatterNumber,
required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,), 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,
),
),
] ]

View File

@ -183,6 +183,9 @@
"temperature_offset": { "temperature_offset": {
"name": "Temperature offset" "name": "Temperature offset"
}, },
"temperature_setpoint": {
"name": "Temperature setpoint"
},
"pir_occupied_to_unoccupied_delay": { "pir_occupied_to_unoccupied_delay": {
"name": "Occupied to unoccupied delay" "name": "Occupied to unoccupied delay"
}, },

View File

@ -1846,6 +1846,64 @@
'state': '0.0', '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': <NumberMode.SLIDER: 'slider'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# 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': <NumberMode.SLIDER: 'slider'>,
'step': 1.0,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.mock_oven_temperature_setpoint',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '76.0',
})
# ---
# name: test_numbers[pump][number.mock_pump_on_level-entry] # name: test_numbers[pump][number.mock_pump_on_level-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({
@ -1903,3 +1961,177 @@
'state': '0', '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': <NumberMode.SLIDER: 'slider'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.laundrywasher_temperature_setpoint',
'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': '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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# 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': <NumberMode.SLIDER: 'slider'>,
'step': 1.0,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.laundrywasher_temperature_setpoint',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# 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': <NumberMode.SLIDER: 'slider'>,
'step': 1.0,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.refrigerator_temperature_setpoint_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.SLIDER: 'slider'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# 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': <NumberMode.SLIDER: 'slider'>,
'step': 1.0,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.refrigerator_temperature_setpoint_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '4.0',
})
# ---

View File

@ -2,6 +2,7 @@
from unittest.mock import MagicMock, call from unittest.mock import MagicMock, call
from chip.clusters import Objects as clusters
from matter_server.client.models.node import MatterNode from matter_server.client.models.node import MatterNode
from matter_server.common import custom_clusters from matter_server.common import custom_clusters
from matter_server.common.errors import MatterError 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"]) @pytest.mark.parametrize("node_fixture", ["dimmable_light"])
async def test_matter_exception_on_write_attribute( async def test_matter_exception_on_write_attribute(
hass: HomeAssistant, hass: HomeAssistant,