From e7a06046a7f1732bc4a74c4ab64a70c8ee3a2beb Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 6 Dec 2022 20:28:06 +0100 Subject: [PATCH] Add matter binary sensor platform (#83144) --- .../components/matter/binary_sensor.py | 95 +++++++++++++++ .../components/matter/device_platform.py | 2 + .../matter/fixtures/nodes/contact-sensor.json | 111 ++++++++++++++++++ .../fixtures/nodes/occupancy-sensor.json | 107 +++++++++++++++++ tests/components/matter/test_binary_sensor.py | 69 +++++++++++ 5 files changed, 384 insertions(+) create mode 100644 homeassistant/components/matter/binary_sensor.py create mode 100644 tests/components/matter/test_binary_sensor.py diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py new file mode 100644 index 00000000000..a4ca54920fb --- /dev/null +++ b/homeassistant/components/matter/binary_sensor.py @@ -0,0 +1,95 @@ +"""Matter binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING + +from chip.clusters import Objects as clusters +from matter_server.common.models import device_types + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +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 binary sensor from Config Entry.""" + matter: MatterAdapter = hass.data[DOMAIN][config_entry.entry_id] + matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) + + +class MatterBinarySensor(MatterEntity, BinarySensorEntity): + """Representation of a Matter binary sensor.""" + + entity_description: MatterBinarySensorEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._attr_is_on = self._device_type_instance.get_cluster( + clusters.BooleanState + ).stateValue + + +class MatterOccupancySensor(MatterBinarySensor): + """Representation of a Matter occupancy sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OCCUPANCY + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + occupancy = self._device_type_instance.get_cluster( + clusters.OccupancySensing + ).occupancy + # The first bit = if occupied + self._attr_is_on = occupancy & 1 == 1 + + +@dataclass +class MatterBinarySensorEntityDescription( + BinarySensorEntityDescription, + MatterEntityDescriptionBaseClass, +): + """Matter Binary Sensor entity description.""" + + +# You can't set default values on inherited data classes +MatterSensorEntityDescriptionFactory = partial( + MatterBinarySensorEntityDescription, entity_cls=MatterBinarySensor +) + +DEVICE_ENTITY: dict[ + type[device_types.DeviceType], + MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], +] = { + device_types.ContactSensor: MatterSensorEntityDescriptionFactory( + key=device_types.ContactSensor, + name="Contact", + subscribe_attributes=(clusters.BooleanState.Attributes.StateValue,), + device_class=BinarySensorDeviceClass.DOOR, + ), + device_types.OccupancySensor: MatterSensorEntityDescriptionFactory( + key=device_types.OccupancySensor, + name="Occupancy", + entity_cls=MatterOccupancySensor, + subscribe_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), + ), +} diff --git a/homeassistant/components/matter/device_platform.py b/homeassistant/components/matter/device_platform.py index 25a83d28b98..3a4d11ab95f 100644 --- a/homeassistant/components/matter/device_platform.py +++ b/homeassistant/components/matter/device_platform.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING 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 if TYPE_CHECKING: @@ -20,5 +21,6 @@ DEVICE_PLATFORM: dict[ MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], ], ] = { + Platform.BINARY_SENSOR: BINARY_SENSOR_DEVICE_ENTITY, Platform.LIGHT: LIGHT_DEVICE_ENTITY, } diff --git a/tests/components/matter/fixtures/nodes/contact-sensor.json b/tests/components/matter/fixtures/nodes/contact-sensor.json index 6890b38e8f1..2aec6a32516 100644 --- a/tests/components/matter/fixtures/nodes/contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/contact-sensor.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, @@ -541,5 +648,9 @@ } }, "endpoints": [0, 1], + "root_device_type_instance": null, + "aggregator_device_type_instance": null, + "device_type_instances": [null], + "node_devices": [null], "_type": "matter_server.common.models.node.MatterNode" } diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy-sensor.json index 2944853f9e1..3e16b92f261 100644 --- a/tests/components/matter/fixtures/nodes/occupancy-sensor.json +++ b/tests/components/matter/fixtures/nodes/occupancy-sensor.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_binary_sensor.py b/tests/components/matter/test_binary_sensor.py new file mode 100644 index 00000000000..522cda2dccc --- /dev/null +++ b/tests/components/matter/test_binary_sensor.py @@ -0,0 +1,69 @@ +"""Test Matter binary sensors.""" +from unittest.mock import MagicMock + +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="contact_sensor_node") +async def contact_sensor_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a contact sensor node.""" + return await setup_integration_with_node_fixture( + hass, "contact-sensor", matter_client + ) + + +async def test_contact_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + contact_sensor_node: MatterNode, +) -> None: + """Test contact sensor.""" + state = hass.states.get("binary_sensor.mock_contact_sensor_contact") + assert state + assert state.state == "on" + + set_node_attribute(contact_sensor_node, 1, 69, 0, False) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.mock_contact_sensor_contact") + assert state + assert state.state == "off" + + +@pytest.fixture(name="occupancy_sensor_node") +async def occupancy_sensor_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a occupancy sensor node.""" + return await setup_integration_with_node_fixture( + hass, "occupancy-sensor", matter_client + ) + + +async def test_occupancy_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + occupancy_sensor_node: MatterNode, +) -> None: + """Test occupancy sensor.""" + state = hass.states.get("binary_sensor.mock_occupancy_sensor_occupancy") + assert state + assert state.state == "on" + + set_node_attribute(occupancy_sensor_node, 1, 1030, 0, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.mock_occupancy_sensor_occupancy") + assert state + assert state.state == "off"