From a9e14cd8d7a1422e6dac3b6b8142ba330bd41a49 Mon Sep 17 00:00:00 2001 From: hidaris Date: Tue, 4 Apr 2023 20:16:11 +0800 Subject: [PATCH] Preliminary support for Matter cover (#90262) Preliminary support for Matter cover, curtain tilt support has not been added yet. --- homeassistant/components/matter/cover.py | 153 ++++ homeassistant/components/matter/discovery.py | 2 + .../fixtures/nodes/window-covering.json | 721 ++++++++++++++++++ tests/components/matter/test_cover.py | 141 ++++ 4 files changed, 1017 insertions(+) create mode 100644 homeassistant/components/matter/cover.py create mode 100644 tests/components/matter/fixtures/nodes/window-covering.json create mode 100644 tests/components/matter/test_cover.py diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py new file mode 100644 index 00000000000..487594561d8 --- /dev/null +++ b/homeassistant/components/matter/cover.py @@ -0,0 +1,153 @@ +"""Matter cover.""" +from __future__ import annotations + +from enum import IntEnum +from typing import Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverEntity, + CoverEntityDescription, + CoverEntityFeature, +) +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 LOGGER +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +# The MASK used for extracting bits 0 to 1 of the byte. +OPERATIONAL_STATUS_MASK = 0b11 + + +class OperationalStatus(IntEnum): + """Currently ongoing operations enumeration for coverings, as defined in the Matter spec.""" + + COVERING_IS_CURRENTLY_NOT_MOVING = 0b00 + COVERING_IS_CURRENTLY_OPENING = 0b01 + COVERING_IS_CURRENTLY_CLOSING = 0b10 + RESERVED = 0b11 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter Cover from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.COVER, async_add_entities) + + +class MatterCover(MatterEntity, CoverEntity): + """Representation of a Matter Cover.""" + + entity_description: CoverEntityDescription + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + if self._attr_current_cover_position: + current_position = self._attr_current_cover_position + else: + current_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage + ) + + assert current_position is not None + + return current_position + + @property + def is_closed(self) -> bool: + """Return true if cover is closed, else False.""" + return self.current_cover_position == 0 + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover movement.""" + await self.send_device_command(clusters.WindowCovering.Commands.StopMotion()) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.UpOrOpen()) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_device_command(clusters.WindowCovering.Commands.DownOrClose()) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Set the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + await self.send_device_command( + clusters.WindowCovering.Commands.GoToLiftValue(position) + ) + + async def send_device_command(self, command: Any) -> None: + """Send device command.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + operational_status = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.OperationalStatus + ) + + assert operational_status is not None + + LOGGER.debug( + "Operational status %s for %s", + f"{operational_status:#010b}", + self.entity_id, + ) + + state = operational_status & OPERATIONAL_STATUS_MASK + match state: + case OperationalStatus.COVERING_IS_CURRENTLY_OPENING: + self._attr_is_opening = True + self._attr_is_closing = False + case OperationalStatus.COVERING_IS_CURRENTLY_CLOSING: + self._attr_is_opening = False + self._attr_is_closing = True + case _: + self._attr_is_opening = False + self._attr_is_closing = False + + self._attr_current_cover_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage + ) + LOGGER.debug( + "Current position: %s for %s", + self._attr_current_cover_position, + self.entity_id, + ) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCover"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.OperationalStatus, + ), + ), +] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 9df4484e00d..28f5b6b7f90 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -10,6 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS +from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo @@ -18,6 +19,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + Platform.COVER: COVER_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, diff --git a/tests/components/matter/fixtures/nodes/window-covering.json b/tests/components/matter/fixtures/nodes/window-covering.json new file mode 100644 index 00000000000..4bc6d8e681d --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering.json @@ -0,0 +1,721 @@ +{ + "node_id": 1, + "date_commissioned": "2023-03-29T08:23:30.740085", + "last_interview": "2023-03-29T08:23:30.740087", + "interview_version": 2, + "available": true, + "attributes": { + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 29, + 30, + 31, + 40, + 42, + 43, + 44, + 45, + 48, + 49, + 50, + 51, + 54, + 60, + 62, + 63, + 64, + 65 + ], + "0/29/2": [ + 41 + ], + "0/29/3": [ + 1 + ], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [ + 0, + 1, + 2, + 3, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/30/0": [], + "0/30/65532": 0, + "0/30/65533": 1, + "0/30/65528": [], + "0/30/65529": [], + "0/30/65531": [ + 0, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [ + 112233 + ], + "targets": null, + "fabricIndex": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [ + 0, + 1, + 2, + 3, + 4, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/40/0": 1, + "0/40/1": "Eliteu", + "0/40/2": 4895, + "0/40/3": "Longan link WNCV DA01", + "0/40/4": 12288, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "3c70c712bd34e54acebd1a8371f56f7d", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "7630EF9998EDF03C", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [ + 0 + ], + "0/42/65531": [ + 0, + 1, + 2, + 3, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [ + 0, + 1, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 7 + ], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [ + 0, + 1, + 2, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/45/0": 0, + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [ + 0, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [ + 1, + 3, + 5 + ], + "0/48/65529": [ + 0, + 2, + 4 + ], + "0/48/65531": [ + 0, + 1, + 2, + 3, + 4, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "TE9OR0FOLUlPVA==", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "TE9OR0FOLUlPVA==", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [ + 1, + 5, + 7 + ], + "0/49/65529": [ + 0, + 2, + 4, + 6, + 8 + ], + "0/49/65531": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [ + 1 + ], + "0/50/65529": [ + 0 + ], + "0/50/65531": [ + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "hPcDB5/k", + "IPv4Addresses": [ + "wKgIhg==" + ], + "IPv6Addresses": [ + "/oAAAAAAAACG9wP//gef5A==", + "JA4DsgZ+bsCG9wP//gef5A==" + ], + "type": 1 + } + ], + "0/51/1": 35, + "0/51/2": 123, + "0/51/3": 0, + "0/51/4": 6, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [ + 0 + ], + "0/51/65531": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/54/0": "mJfMGB1w", + "0/54/1": 0, + "0/54/2": 3, + "0/54/3": 1, + "0/54/4": -36, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [ + 0, + 1, + 2, + 3, + 4, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [ + 0, + 1, + 2 + ], + "0/60/65531": [ + 0, + 1, + 2, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "fabricIndex": 2 + } + ], + "0/62/1": [ + { + "rootPublicKey": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQAkAgE3AycUBZIG4P1iqI0kFQEYJgRBkLUrJgXBw5YtNwYnFAWSBuD9YqiNJBUBGCQHASQIATAJQQRruztKRDFfiVjMY19sSsnKqBZJlZrQ/ClUtTYatvOZxbTC53iCqhwHaIJthMWs7ICwtSX1Vr5lGkzDXQjH/oQ6Nwo1ASkBGCQCYDAEFJd2wRMLYsFFA1PRCdMviVipH3OWMAUUl3bBEwtiwUUDU9EJ0y+JWKkfc5YYMAtASJa3FJ84kws+OOWNEMgRvcZA/d0AJVmmoqoWrorxxfpVKujZuN8Kc193rwBckfxd69s3OS1y8HCZTtooCemIpBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEUsyszWxq0GlTQM3iyuL9LgBWj+6CZkKd0F887fdD5kIoNnM2Ew4HTXj6FmzQovu8+yxq9PtNv2e3Rb6Sl4B7RTcKNQEpARgkAmAwBBRQgiuTWL+gqw+f9Etus1wVMBtFgTAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQFyHXux9szIosC1gP+/1/7BX3PfGaX2GF172oHSAoMXnLJ7OawkzgWIykEj7oRIjKv3XRR27y3KhV83817SfCOkY" + ], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [ + 1, + 3, + 5, + 8 + ], + "0/62/65529": [ + 0, + 2, + 4, + 6, + 7, + 9, + 10, + 11 + ], + "0/62/65531": [ + 0, + 1, + 2, + 3, + 4, + 5, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [ + 2, + 5 + ], + "0/63/65529": [ + 0, + 1, + 3, + 4 + ], + "0/63/65531": [ + 0, + 1, + 2, + 3, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [ + 0, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [ + 0, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "1/3/0": 0, + "1/3/1": 2, + "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, + "1/4/65528": [ + 0, + 1, + 2, + 3 + ], + "1/4/65529": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "1/4/65531": [ + 0, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "1/29/0": [ + { + "type": 514, + "revision": 1 + } + ], + "1/29/1": [ + 3, + 4, + 29, + 30, + 64, + 65, + 258 + ], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [ + 0, + 1, + 2, + 3, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "1/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [ + 0, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "1/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "1/64/65532": 0, + "1/64/65533": 1, + "1/64/65528": [], + "1/64/65529": [], + "1/64/65531": [ + 0, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "1/65/0": [], + "1/65/65532": 0, + "1/65/65533": 1, + "1/65/65528": [], + "1/65/65529": [], + "1/65/65531": [ + 0, + 65528, + 65529, + 65531, + 65532, + 65533 + ], + "1/258/0": 0, + "1/258/1": 0, + "1/258/3": 0, + "1/258/5": 0, + "1/258/7": 11, + "1/258/8": 100, + "1/258/10": 0, + "1/258/11": 0, + "1/258/13": 0, + "1/258/14": 4900, + "1/258/16": 0, + "1/258/17": 65535, + "1/258/23": 0, + "1/258/65532": 13, + "1/258/65533": 5, + "1/258/65528": [], + "1/258/65529": [ + 0, + 1, + 2, + 4, + 5, + 18, + 19 + ], + "1/258/65531": [ + 0, + 1, + 3, + 5, + 7, + 8, + 10, + 11, + 13, + 14, + 16, + 17, + 23, + 65528, + 65529, + 65531, + 65532, + 65533 + ] + } +} \ No newline at end of file diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py new file mode 100644 index 00000000000..b967436f093 --- /dev/null +++ b/tests/components/matter/test_cover.py @@ -0,0 +1,141 @@ +"""Test Matter covers.""" +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.components.cover import ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="window_covering") +async def window_covering_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a window covering node.""" + return await setup_integration_with_node_fixture( + hass, "window-covering", matter_client + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_cover( + hass: HomeAssistant, + matter_client: MagicMock, + window_covering: MatterNode, +) -> None: + """Test window covering.""" + await hass.services.async_call( + "cover", + "close_cover", + { + "entity_id": "cover.longan_link_wncv_da01", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=window_covering.node_id, + endpoint_id=1, + command=clusters.WindowCovering.Commands.DownOrClose(), + ) + matter_client.send_device_command.reset_mock() + + await hass.services.async_call( + "cover", + "stop_cover", + { + "entity_id": "cover.longan_link_wncv_da01", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=window_covering.node_id, + endpoint_id=1, + command=clusters.WindowCovering.Commands.StopMotion(), + ) + matter_client.send_device_command.reset_mock() + + await hass.services.async_call( + "cover", + "open_cover", + { + "entity_id": "cover.longan_link_wncv_da01", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=window_covering.node_id, + endpoint_id=1, + command=clusters.WindowCovering.Commands.UpOrOpen(), + ) + matter_client.send_device_command.reset_mock() + + await hass.services.async_call( + "cover", + "set_cover_position", + { + "entity_id": "cover.longan_link_wncv_da01", + "position": 50, + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=window_covering.node_id, + endpoint_id=1, + command=clusters.WindowCovering.Commands.GoToLiftValue(50), + ) + matter_client.send_device_command.reset_mock() + + set_node_attribute(window_covering, 1, 258, 8, 30) + set_node_attribute(window_covering, 1, 258, 10, 2) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("cover.longan_link_wncv_da01") + assert state + assert state.state == STATE_CLOSING + + set_node_attribute(window_covering, 1, 258, 8, 0) + set_node_attribute(window_covering, 1, 258, 10, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("cover.longan_link_wncv_da01") + assert state + assert state.state == STATE_CLOSED + + set_node_attribute(window_covering, 1, 258, 8, 50) + set_node_attribute(window_covering, 1, 258, 10, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("cover.longan_link_wncv_da01") + assert state + assert state.state == STATE_OPENING + + set_node_attribute(window_covering, 1, 258, 8, 100) + set_node_attribute(window_covering, 1, 258, 10, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("cover.longan_link_wncv_da01") + assert state + assert state.attributes["current_position"] == 100 + assert state.state == STATE_OPEN