diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py new file mode 100644 index 00000000000..918b334061b --- /dev/null +++ b/homeassistant/components/matter/button.py @@ -0,0 +1,149 @@ +"""Matter Button platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +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 Button platform.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.BUTTON, async_add_entities) + + +@dataclass(frozen=True) +class MatterButtonEntityDescription(ButtonEntityDescription, MatterEntityDescription): + """Describe Matter Button entities.""" + + command: Callable[[], Any] | None = None + + +class MatterCommandButton(MatterEntity, ButtonEntity): + """Representation of a Matter Button entity.""" + + entity_description: MatterButtonEntityDescription + + async def async_press(self) -> None: + """Handle the button press leveraging a Matter command.""" + if TYPE_CHECKING: + assert self.entity_description.command is not None + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=self.entity_description.command(), + ) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="IdentifyButton", + entity_category=EntityCategory.CONFIG, + device_class=ButtonDeviceClass.IDENTIFY, + command=lambda: clusters.Identify.Commands.Identify(identifyTime=15), + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,), + value_contains=clusters.Identify.Commands.Identify.command_id, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="OperationalStatePauseButton", + translation_key="pause", + command=clusters.OperationalState.Commands.Pause, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.OperationalState.Attributes.AcceptedCommandList,), + value_contains=clusters.OperationalState.Commands.Pause.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="OperationalStateResumeButton", + translation_key="resume", + command=clusters.OperationalState.Commands.Resume, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.OperationalState.Attributes.AcceptedCommandList,), + value_contains=clusters.OperationalState.Commands.Resume.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="OperationalStateStartButton", + translation_key="start", + command=clusters.OperationalState.Commands.Start, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.OperationalState.Attributes.AcceptedCommandList,), + value_contains=clusters.OperationalState.Commands.Start.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="OperationalStateStopButton", + translation_key="stop", + command=clusters.OperationalState.Commands.Stop, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.OperationalState.Attributes.AcceptedCommandList,), + value_contains=clusters.OperationalState.Commands.Stop.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="HepaFilterMonitoringResetButton", + translation_key="reset_filter_condition", + command=clusters.HepaFilterMonitoring.Commands.ResetCondition, + ), + entity_class=MatterCommandButton, + required_attributes=( + clusters.HepaFilterMonitoring.Attributes.AcceptedCommandList, + ), + value_contains=clusters.HepaFilterMonitoring.Commands.ResetCondition.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="ActivatedCarbonFilterMonitoringResetButton", + translation_key="reset_filter_condition", + command=clusters.ActivatedCarbonFilterMonitoring.Commands.ResetCondition, + ), + entity_class=MatterCommandButton, + required_attributes=( + clusters.ActivatedCarbonFilterMonitoring.Attributes.AcceptedCommandList, + ), + value_contains=clusters.ActivatedCarbonFilterMonitoring.Commands.ResetCondition.command_id, + allow_multi=True, + ), +] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c3e347e9808..5544409e0ba 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -11,6 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS +from .button import DISCOVERY_SCHEMAS as BUTTON_SCHEMAS from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS @@ -26,6 +27,7 @@ from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + Platform.BUTTON: BUTTON_SCHEMAS, Platform.CLIMATE: CLIMATE_SENSOR_SCHEMAS, Platform.COVER: COVER_SCHEMAS, Platform.EVENT: EVENT_SCHEMAS, @@ -114,6 +116,16 @@ def async_discover_entities( ): continue + # check for required value in (primary) attribute + if schema.value_contains is not None and ( + (primary_attribute := next((x for x in schema.required_attributes), None)) + is None + or (value := endpoint.get_attribute_value(None, primary_attribute)) is None + or not isinstance(value, list) + or schema.value_contains not in value + ): + continue + # all checks passed, this value belongs to an entity attributes_to_watch = list(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 61e29477585..5e6007f4418 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass from functools import cached_property @@ -158,7 +157,6 @@ class MatterEntity(Entity): self.async_write_ha_state() @callback - @abstractmethod def _update_from_device(self) -> None: """Update data from Matter device.""" diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 5d9a7aaf477..32c9f057e47 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -5,6 +5,20 @@ "default": "mdi:bell-off" } }, + "button": { + "pause": { + "default": "mdi:pause" + }, + "resume": { + "default": "mdi:play-pause" + }, + "start": { + "default": "mdi:play" + }, + "stop": { + "default": "mdi:stop" + } + }, "fan": { "fan": { "state_attributes": { diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index c9488437a06..f04c0f7e107 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TypedDict +from typing import Any, TypedDict from chip.clusters import Objects as clusters from chip.clusters.Objects import Cluster, ClusterAttributeDescriptor @@ -108,6 +108,11 @@ class MatterDiscoverySchema: # are not discovered by other entities optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None + # [optional] the primary attribute value must contain this value + # for example for the AcceptedCommandList + # NOTE: only works for list values + value_contains: Any | None = None + # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index fdff15ce0a4..5a268c1c371 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -77,6 +77,23 @@ "name": "Muted" } }, + "button": { + "pause": { + "name": "[%key:common::action::pause%]" + }, + "resume": { + "name": "Resume" + }, + "start": { + "name": "[%key:common::action::start%]" + }, + "stop": { + "name": "[%key:common::action::stop%]" + }, + "reset_filter_condition": { + "name": "Reset filter condition" + } + }, "climate": { "thermostat": { "name": "Thermostat" diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index 58c22f1b807..f8a3b28fb9e 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -305,13 +305,6 @@ "0/65/65528": [], "0/65/65529": [], "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], - "1/3/0": 0, - "1/3/1": 0, - "1/3/65532": 0, - "1/3/65533": 4, - "1/3/65528": [], - "1/3/65529": [0, 64], - "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/4/0": 128, "1/4/65532": 1, "1/4/65533": 4, diff --git a/tests/components/matter/fixtures/nodes/silabs-dishwasher.json b/tests/components/matter/fixtures/nodes/silabs-dishwasher.json index f060066e100..c5015bc1c34 100644 --- a/tests/components/matter/fixtures/nodes/silabs-dishwasher.json +++ b/tests/components/matter/fixtures/nodes/silabs-dishwasher.json @@ -444,7 +444,7 @@ "1/96/65532": 0, "1/96/65533": 1, "1/96/65528": [4], - "1/96/65529": [0, 1, 2, 3], + "1/96/65529": [0, 1, 2], "1/96/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], "2/29/0": [ { diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py new file mode 100644 index 00000000000..e57a20d1533 --- /dev/null +++ b/tests/components/matter/test_button.py @@ -0,0 +1,89 @@ +"""Test Matter switches.""" + +from unittest.mock import MagicMock, call + +from chip.clusters import Objects as clusters +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.core import HomeAssistant + +from .common import setup_integration_with_node_fixture + + +@pytest.fixture(name="powerplug_node") +async def powerplug_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Powerplug node.""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug", matter_client + ) + + +@pytest.fixture(name="dishwasher_node") +async def dishwasher_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for an dishwasher node.""" + return await setup_integration_with_node_fixture( + hass, "silabs-dishwasher", matter_client + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_identify_button( + hass: HomeAssistant, + matter_client: MagicMock, + powerplug_node: MatterNode, +) -> None: + """Test button entity is created for a Matter Identify Cluster.""" + state = hass.states.get("button.eve_energy_plug_identify") + assert state + assert state.attributes["friendly_name"] == "Eve Energy Plug Identify" + # test press action + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.eve_energy_plug_identify", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=powerplug_node.node_id, + endpoint_id=1, + command=clusters.Identify.Commands.Identify(identifyTime=15), + ) + + +async def test_operational_state_buttons( + hass: HomeAssistant, + matter_client: MagicMock, + dishwasher_node: MatterNode, +) -> None: + """Test if button entities are created for operational state commands.""" + assert hass.states.get("button.dishwasher_pause") + assert hass.states.get("button.dishwasher_start") + assert hass.states.get("button.dishwasher_stop") + + # resume may not be disocvered as its missing in the supported command list + assert hass.states.get("button.dishwasher_resume") is None + + # test press action + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.dishwasher_pause", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=dishwasher_node.node_id, + endpoint_id=1, + command=clusters.OperationalState.Commands.Pause(), + )