diff --git a/homeassistant/components/matter/device_platform.py b/homeassistant/components/matter/device_platform.py index 02267b02880..24e7f8b5dc4 100644 --- a/homeassistant/components/matter/device_platform.py +++ b/homeassistant/components/matter/device_platform.py @@ -8,6 +8,7 @@ from homeassistant.const import Platform from .binary_sensor import DEVICE_ENTITY as BINARY_SENSOR_DEVICE_ENTITY from .light import DEVICE_ENTITY as LIGHT_DEVICE_ENTITY from .sensor import DEVICE_ENTITY as SENSOR_DEVICE_ENTITY +from .switch import DEVICE_ENTITY as SWITCH_DEVICE_ENTITY if TYPE_CHECKING: from matter_server.common.models.device_types import DeviceType @@ -25,4 +26,5 @@ DEVICE_PLATFORM: dict[ Platform.BINARY_SENSOR: BINARY_SENSOR_DEVICE_ENTITY, Platform.LIGHT: LIGHT_DEVICE_ENTITY, Platform.SENSOR: SENSOR_DEVICE_ENTITY, + Platform.SWITCH: SWITCH_DEVICE_ENTITY, } diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py new file mode 100644 index 00000000000..b7c2b999918 --- /dev/null +++ b/homeassistant/components/matter/switch.py @@ -0,0 +1,88 @@ +"""Matter switches.""" +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING, Any + +from chip.clusters import Objects as clusters +from matter_server.common.models import device_types + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import MatterEntity, MatterEntityDescriptionBaseClass + +if TYPE_CHECKING: + from .adapter import MatterAdapter + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter switches from Config Entry.""" + matter: MatterAdapter = hass.data[DOMAIN][config_entry.entry_id] + matter.register_platform_handler(Platform.SWITCH, async_add_entities) + + +class MatterSwitch(MatterEntity, SwitchEntity): + """Representation of a Matter switch.""" + + entity_description: MatterSwitchEntityDescription + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + await self.matter_client.send_device_command( + node_id=self._device_type_instance.node.node_id, + endpoint=self._device_type_instance.endpoint, + command=clusters.OnOff.Commands.On(), + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + await self.matter_client.send_device_command( + node_id=self._device_type_instance.node.node_id, + endpoint=self._device_type_instance.endpoint, + command=clusters.OnOff.Commands.Off(), + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._attr_is_on = self._device_type_instance.get_cluster(clusters.OnOff).onOff + + +@dataclass +class MatterSwitchEntityDescription( + SwitchEntityDescription, + MatterEntityDescriptionBaseClass, +): + """Matter Switch entity description.""" + + +# You can't set default values on inherited data classes +MatterSwitchEntityDescriptionFactory = partial( + MatterSwitchEntityDescription, entity_cls=MatterSwitch +) + + +DEVICE_ENTITY: dict[ + type[device_types.DeviceType], + MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], +] = { + device_types.OnOffPlugInUnit: MatterSwitchEntityDescriptionFactory( + key=device_types.OnOffPlugInUnit, + subscribe_attributes=(clusters.OnOff.Attributes.OnOff,), + device_class=SwitchDeviceClass.OUTLET, + ), +} diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index dd1c005bdf8..cbbe39b1f09 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -4,6 +4,113 @@ "last_interview": "2022-11-29T21:23:48.485057", "interview_version": 1, "attributes": { + "0/29/0": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 0, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", + "attribute_name": "DeviceTypeList", + "value": [ + { + "type": 22, + "revision": 1 + } + ] + }, + "0/29/1": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 1, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", + "attribute_name": "ServerList", + "value": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, + 63, 64, 65 + ] + }, + "0/29/2": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 2, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", + "attribute_name": "ClientList", + "value": [41] + }, + "0/29/3": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 3, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", + "attribute_name": "PartsList", + "value": [1] + }, + "0/29/65532": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 65532, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", + "attribute_name": "FeatureMap", + "value": 0 + }, + "0/29/65533": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 65533, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", + "attribute_name": "ClusterRevision", + "value": 1 + }, + "0/29/65528": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 65528, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", + "attribute_name": "GeneratedCommandList", + "value": [] + }, + "0/29/65529": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 65529, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", + "attribute_name": "AcceptedCommandList", + "value": [] + }, + "0/29/65531": { + "node_id": 1, + "endpoint": 0, + "cluster_id": 29, + "cluster_type": "chip.clusters.Objects.Descriptor", + "cluster_name": "Descriptor", + "attribute_id": 65531, + "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", + "attribute_name": "AttributeList", + "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, "0/40/0": { "node_id": 1, "endpoint": 0, diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py new file mode 100644 index 00000000000..a79edd6010b --- /dev/null +++ b/tests/components/matter/test_switch.py @@ -0,0 +1,85 @@ +"""Test Matter switches.""" +from unittest.mock import MagicMock, call + +from chip.clusters import Objects as clusters +from matter_server.common.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="switch_node") +async def switch_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a switch node.""" + return await setup_integration_with_node_fixture( + hass, "on-off-plugin-unit", matter_client + ) + + +async def test_turn_on( + hass: HomeAssistant, + matter_client: MagicMock, + switch_node: MatterNode, +) -> None: + """Test turning on a switch.""" + state = hass.states.get("switch.mock_onoff_plugin_unit") + assert state + assert state.state == "off" + + await hass.services.async_call( + "switch", + "turn_on", + { + "entity_id": "switch.mock_onoff_plugin_unit", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=switch_node.node_id, + endpoint=1, + command=clusters.OnOff.Commands.On(), + ) + + set_node_attribute(switch_node, 1, 6, 0, True) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("switch.mock_onoff_plugin_unit") + assert state + assert state.state == "on" + + +async def test_turn_off( + hass: HomeAssistant, + matter_client: MagicMock, + switch_node: MatterNode, +) -> None: + """Test turning off a switch.""" + state = hass.states.get("switch.mock_onoff_plugin_unit") + assert state + assert state.state == "off" + + await hass.services.async_call( + "switch", + "turn_off", + { + "entity_id": "switch.mock_onoff_plugin_unit", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=switch_node.node_id, + endpoint=1, + command=clusters.OnOff.Commands.Off(), + )