From 12f812d6da75124a249a7f03ff881518610a3176 Mon Sep 17 00:00:00 2001 From: jvmahon Date: Fri, 21 Jun 2024 09:53:50 -0400 Subject: [PATCH] Add number platform to Matter integration (#119770) Co-authored-by: Franck Nijhof Co-authored-by: Marcel van der Veldt --- homeassistant/components/matter/discovery.py | 2 + homeassistant/components/matter/entity.py | 1 + homeassistant/components/matter/number.py | 140 +++++++++++++++++++ homeassistant/components/matter/strings.json | 14 ++ tests/components/matter/test_number.py | 56 ++++++++ 5 files changed, 213 insertions(+) create mode 100644 homeassistant/components/matter/number.py create mode 100644 tests/components/matter/test_number.py diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index d69c2393083..b457be8583c 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -17,6 +17,7 @@ from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo +from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS @@ -28,6 +29,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.FAN: FAN_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, + Platform.NUMBER: NUMBER_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index ded1e1a2d39..876693f354f 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -34,6 +34,7 @@ class MatterEntityDescription(EntityDescription): # convert the value from the primary attribute to the value used by HA measurement_to_ha: Callable[[Any], Any] | None = None + ha_to_native_value: Callable[[Any], Any] | None = None class MatterEntity(Entity): diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py new file mode 100644 index 00000000000..c9b40ef71a0 --- /dev/null +++ b/homeassistant/components/matter/number.py @@ -0,0 +1,140 @@ +"""Matter Number Inputs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from chip.clusters import Objects as clusters +from matter_server.common.helpers.util import create_attribute_path_from_attribute + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import MatterEntity, MatterEntityDescription +from .helpers import get_matter +from .models import MatterDiscoverySchema + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter Number Input from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.NUMBER, async_add_entities) + + +@dataclass(frozen=True) +class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescription): + """Describe Matter Number Input entities.""" + + +class MatterNumber(MatterEntity, NumberEntity): + """Representation of a Matter Attribute as a Number entity.""" + + entity_description: MatterNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + matter_attribute = self._entity_info.primary_attribute + sendvalue = int(value) + if value_convert := self.entity_description.ha_to_native_value: + sendvalue = value_convert(value) + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + matter_attribute, + ), + value=sendvalue, + ) + + @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 + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="on_level", + entity_category=EntityCategory.CONFIG, + translation_key="on_level", + native_max_value=255, + native_min_value=0, + mode=NumberMode.BOX, + # use 255 to indicate that the value should revert to the default + measurement_to_ha=lambda x: 255 if x is None else x, + ha_to_native_value=lambda x: None if x == 255 else int(x), + native_step=1, + native_unit_of_measurement=None, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.OnLevel,), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="on_transition_time", + entity_category=EntityCategory.CONFIG, + translation_key="on_transition_time", + native_max_value=65534, + native_min_value=0, + measurement_to_ha=lambda x: None if x is None else x / 10, + ha_to_native_value=lambda x: round(x * 10), + native_step=0.1, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.OnTransitionTime,), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="off_transition_time", + entity_category=EntityCategory.CONFIG, + translation_key="off_transition_time", + native_max_value=65534, + native_min_value=0, + measurement_to_ha=lambda x: None if x is None else x / 10, + ha_to_native_value=lambda x: round(x * 10), + native_step=0.1, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.OffTransitionTime,), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="on_off_transition_time", + entity_category=EntityCategory.CONFIG, + translation_key="on_off_transition_time", + native_max_value=65534, + native_min_value=0, + measurement_to_ha=lambda x: None if x is None else x / 10, + ha_to_native_value=lambda x: round(x * 10), + native_step=0.1, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.OnOffTransitionTime,), + ), +] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index a3f26a5865a..190aae5de43 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -78,6 +78,20 @@ } } }, + "number": { + "on_level": { + "name": "On level" + }, + "on_transition_time": { + "name": "On transition time" + }, + "off_transition_time": { + "name": "Off transition time" + }, + "on_off_transition_time": { + "name": "On/Off transition time" + } + }, "sensor": { "activated_carbon_filter_condition": { "name": "Activated carbon filter condition" diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py new file mode 100644 index 00000000000..917f8138c7a --- /dev/null +++ b/tests/components/matter/test_number.py @@ -0,0 +1,56 @@ +"""Test Matter number entities.""" + +from unittest.mock import MagicMock + +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="light_node") +async def dimmable_light_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a flow sensor node.""" + return await setup_integration_with_node_fixture( + hass, "dimmable-light", matter_client + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_level_control_config_entities( + hass: HomeAssistant, + matter_client: MagicMock, + light_node: MatterNode, +) -> None: + """Test number entities are created for the LevelControl cluster (config) attributes.""" + state = hass.states.get("number.mock_dimmable_light_on_level") + assert state + assert state.state == "255" + + state = hass.states.get("number.mock_dimmable_light_on_transition_time") + assert state + assert state.state == "0.0" + + state = hass.states.get("number.mock_dimmable_light_off_transition_time") + assert state + assert state.state == "0.0" + + state = hass.states.get("number.mock_dimmable_light_on_off_transition_time") + assert state + assert state.state == "0.0" + + set_node_attribute(light_node, 1, 0x00000008, 0x0011, 20) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("number.mock_dimmable_light_on_level") + assert state + assert state.state == "20"