Preliminary support for Matter cover (#90262)

Preliminary support for Matter cover, curtain tilt support has not been added yet.
This commit is contained in:
hidaris 2023-04-04 20:16:11 +08:00 committed by GitHub
parent e962dd64cf
commit a9e14cd8d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 1017 additions and 0 deletions

View file

@ -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,
),
),
]

View file

@ -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,

View file

@ -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
]
}
}

View file

@ -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