mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 09:17:10 +00:00
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:
parent
b630fb0520
commit
e3ba1f34ca
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user