Add Valve platform to Matter integration (#123311)

* Create water_valve.py

* Update water_valve.py

ValveEntity

* Update water_valve.py

ValveDeviceClass

* Update water_valve.py

* Update water_valve.py

OperationalStatus

* Update water_valve.py

* Update water_valve.py

Commands

* Update water_valve.py

Platform.VALVE

* Update water_valve.py

* Update water_valve.py

operational_status

* Update water_valve.py

current_valve_position

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

attributes

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Open command

* Match Valve entity methods

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* ruff-format

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

Attributes.CurrentLevel

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

async_set_valve_position

* Update water_valve.py

* Update water_valve.py

Bitmaps

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update homeassistant/components/matter/water_valve.py

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>

* Update homeassistant/components/matter/water_valve.py

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update discovery.py to add WaterValve

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update discovery.py

* Update discovery.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Update water_valve.py

* Rename water_valve.py to valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* Create test_valve.py

* Update test_valve.py

* Update test_valve.py

* Update test_valve.py

* Update test_valve.py

* Update test_valve.py

* Update test_valve.py

* Update test_valve.py

* Update test_valve.py

* Update test_valve.py

* Create valve.json

* Update air-purifier.json

* Revert "Update air-purifier.json"

This reverts commit b68dce0ccc81bc6fb1db36191de1c296ce54cac3.

* Update valve.json

* Update valve.json

* Update valve.json

* Update test_valve.py

* Update valve.json

* Update test_valve.py

* Update valve.json

* Update valve.json

* Update valve.json

* Update test_valve.py

* Update valve.py

* Update valve.py

* Update valve.py

* add tests

* cleanup

* Clean up variable

* Format

* add tests for state updates

* adjust

* add tests for position

---------

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Ludovic BOUÉ 2024-09-25 20:19:10 +02:00 committed by GitHub
parent 0a44c9456c
commit 6d1e5886ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 550 additions and 0 deletions

View File

@ -24,6 +24,7 @@ from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS
from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS
from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS
from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS
from .valve import DISCOVERY_SCHEMAS as VALVE_SCHEMAS
DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = {
Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS,
@ -39,6 +40,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = {
Platform.SENSOR: SENSOR_SCHEMAS,
Platform.SWITCH: SWITCH_SCHEMAS,
Platform.UPDATE: UPDATE_SCHEMAS,
Platform.VALVE: VALVE_SCHEMAS,
}
SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS)

View File

@ -250,6 +250,11 @@
"power": {
"name": "Power"
}
},
"valve": {
"valve": {
"name": "[%key:component::valve::title%]"
}
}
},
"issues": {

View File

@ -0,0 +1,152 @@
"""Matter valve platform."""
from __future__ import annotations
from chip.clusters import Objects as clusters
from matter_server.client.models import device_types
from homeassistant.components.valve import (
ValveDeviceClass,
ValveEntity,
ValveEntityDescription,
ValveEntityFeature,
)
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 .entity import MatterEntity
from .helpers import get_matter
from .models import MatterDiscoverySchema
ValveConfigurationAndControl = clusters.ValveConfigurationAndControl
ValveStateEnum = ValveConfigurationAndControl.Enums.ValveStateEnum
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Matter valve platform from Config Entry."""
matter = get_matter(hass)
matter.register_platform_handler(Platform.VALVE, async_add_entities)
class MatterValve(MatterEntity, ValveEntity):
"""Representation of a Matter Valve."""
_feature_map: int | None = None
entity_description: ValveEntityDescription
async def send_device_command(
self,
command: clusters.ClusterCommand,
) -> None:
"""Send a command to the device."""
await self.matter_client.send_device_command(
node_id=self._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=command,
)
async def async_open_valve(self) -> None:
"""Open the valve."""
await self.send_device_command(ValveConfigurationAndControl.Commands.Open())
async def async_close_valve(self) -> None:
"""Close the valve."""
await self.send_device_command(ValveConfigurationAndControl.Commands.Close())
async def async_set_valve_position(self, position: int) -> None:
"""Move the valve to a specific position."""
await self.send_device_command(
ValveConfigurationAndControl.Commands.Open(targetLevel=position)
)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
self._calculate_features()
current_state: int
current_state = self.get_matter_attribute_value(
ValveConfigurationAndControl.Attributes.CurrentState
)
target_state: int
target_state = self.get_matter_attribute_value(
ValveConfigurationAndControl.Attributes.TargetState
)
if (
current_state == ValveStateEnum.kTransitioning
and target_state == ValveStateEnum.kOpen
):
self._attr_is_opening = True
self._attr_is_closing = False
elif (
current_state == ValveStateEnum.kTransitioning
and target_state == ValveStateEnum.kClosed
):
self._attr_is_opening = False
self._attr_is_closing = True
elif current_state == ValveStateEnum.kClosed:
self._attr_is_opening = False
self._attr_is_closing = False
self._attr_is_closed = True
else:
self._attr_is_opening = False
self._attr_is_closing = False
self._attr_is_closed = False
# handle optional position
if self.supported_features & ValveEntityFeature.SET_POSITION:
self._attr_current_valve_position = self.get_matter_attribute_value(
ValveConfigurationAndControl.Attributes.CurrentLevel
)
@callback
def _calculate_features(
self,
) -> None:
"""Calculate features for HA Valve platform from Matter FeatureMap."""
feature_map = int(
self.get_matter_attribute_value(
ValveConfigurationAndControl.Attributes.FeatureMap
)
)
# NOTE: the featuremap can dynamically change, so we need to update the
# supported features if the featuremap changes.
# work out supported features and presets from matter featuremap
if self._feature_map == feature_map:
return
self._feature_map = feature_map
self._attr_supported_features = ValveEntityFeature(0)
if feature_map & ValveConfigurationAndControl.Bitmaps.Feature.kLevel:
self._attr_supported_features |= ValveEntityFeature.SET_POSITION
self._attr_reports_position = True
else:
self._attr_reports_position = False
self._attr_supported_features |= (
ValveEntityFeature.CLOSE | ValveEntityFeature.OPEN
)
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.VALVE,
entity_description=ValveEntityDescription(
key="MatterValve",
device_class=ValveDeviceClass.WATER,
translation_key="valve",
),
entity_class=MatterValve,
required_attributes=(
ValveConfigurationAndControl.Attributes.CurrentState,
ValveConfigurationAndControl.Attributes.TargetState,
),
optional_attributes=(ValveConfigurationAndControl.Attributes.CurrentLevel,),
device_type=(device_types.WaterValve,),
),
]

View File

@ -0,0 +1,260 @@
{
"node_id": 75,
"date_commissioned": "2024-09-02T09:32:00.380607",
"last_interview": "2024-09-02T09:32:00.380611",
"interview_version": 6,
"available": true,
"is_bridge": false,
"attributes": {
"0/29/0": [
{
"0": 22,
"1": 1
}
],
"0/29/1": [29, 31, 40, 43, 48, 49, 50, 51, 60, 62, 63],
"0/29/2": [],
"0/29/3": [1],
"0/29/65532": 0,
"0/29/65533": 2,
"0/29/65528": [],
"0/29/65529": [],
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/31/0": [
{
"1": 5,
"2": 2,
"3": [112233],
"4": null,
"254": 1
}
],
"0/31/1": [],
"0/31/2": 4,
"0/31/3": 3,
"0/31/4": 4,
"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": 18,
"0/40/1": "Mock",
"0/40/2": 65521,
"0/40/3": "Valve",
"0/40/4": 32768,
"0/40/5": "",
"0/40/6": "**REDACTED**",
"0/40/7": 0,
"0/40/8": "TEST_VERSION",
"0/40/9": 1,
"0/40/10": "1.0",
"0/40/11": "20200101",
"0/40/12": "",
"0/40/13": "",
"0/40/14": "",
"0/40/15": "TEST_SN",
"0/40/16": false,
"0/40/18": "A3586AC56A2CCCDB",
"0/40/19": {
"0": 3,
"1": 65535
},
"0/40/21": 17039360,
"0/40/22": 1,
"0/40/65532": 0,
"0/40/65533": 2,
"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, 18, 19, 21, 22,
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/48/0": 0,
"0/48/1": {
"0": 60,
"1": 900
},
"0/48/2": 0,
"0/48/3": 2,
"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": [
{
"0": "ZW5zMzM=",
"1": true
}
],
"0/49/2": 0,
"0/49/3": 0,
"0/49/4": true,
"0/49/5": null,
"0/49/6": null,
"0/49/7": null,
"0/49/65532": 4,
"0/49/65533": 2,
"0/49/65528": [],
"0/49/65529": [],
"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": [
{
"0": "ens33",
"1": true,
"2": null,
"3": null,
"4": "AAwpp2CV",
"5": ["wKgBjg=="],
"6": [
"/adI27DsyURo2mqau/5wuw==",
"/adI27DsyUSOe4PwnMXbYg==",
"KgEOCgKzOZD9M4Fh8k4Abg==",
"KgEOCgKzOZCNpPnLBN7MTQ==",
"/oAAAAAAAADvX1kMcjUM+w=="
],
"7": 2
},
{
"0": "lo",
"1": true,
"2": null,
"3": null,
"4": "AAAAAAAA",
"5": ["fwAAAQ=="],
"6": ["AAAAAAAAAAAAAAAAAAAAAQ=="],
"7": 0
}
],
"0/51/1": 1,
"0/51/2": 77,
"0/51/3": 0,
"0/51/4": 0,
"0/51/5": [],
"0/51/6": [],
"0/51/7": [],
"0/51/8": false,
"0/51/65532": 0,
"0/51/65533": 2,
"0/51/65528": [2],
"0/51/65529": [0, 1],
"0/51/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 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": [
{
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRSxgkBwEkCAEwCUEEPt5xWN1i0R+dLM+MnDvosL8hjyrRoHq5ja+iCtZbpXTIXt17ueMKWDc7pgeEvHn9opOCiFvmqjEZ1L4hDk27MTcKNQEoARgkAgE2AwQCBAEYMAQUUPvMnV9FkGhfQedEwlqazBFbVfUwBRQ1L3KS8MJ5RVnuryNgRxdXueDAoxgwC0CA4m5xhFuvxC4iDehajKmbdNvZdo2alIbL8hGTor2jMFIPAowJeA0ZaS0+ocRsA6xxHRrpmmF095qUHbSONrPIGA==",
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEBjOABseGNfeoeNqgBxhNV78q8SfQP8putY2hpTVwmJVaWzyqw4F/OhdJRHTZjXkSV87jHOZ58ivEb3GjFiT+OTcKNQEpARgkAmAwBBQ1L3KS8MJ5RVnuryNgRxdXueDAozAFFM2vLItbAuvwSMsedKJS5Tw7Aa2pGDALQCPtpgnYiXc8JmJmEi25z0BIPFYaf27j9yhVSmm45vjpdSZd3p8uOGjHd23m8w/22q2eWvkzU02qTVLgnV42cgkY",
"254": 1
}
],
"0/62/1": [
{
"1": "BPUiJZj+BQknF7mbNOh2d9ZtKB+gQJLND+2qjIAAaMJb+2BW+xFhqDYYiA8p9YegdTb0wHA1NQY8TXMPyDwoP9Q=",
"2": 4939,
"3": 2,
"4": 75,
"5": "",
"254": 1
}
],
"0/62/2": 16,
"0/62/3": 1,
"0/62/4": [
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE9SIlmP4FCScXuZs06HZ31m0oH6BAks0P7aqMgABowlv7YFb7EWGoNhiIDyn1h6B1NvTAcDU1BjxNcw/IPCg/1DcKNQEpARgkAmAwBBTNryyLWwLr8EjLHnSiUuU8OwGtqTAFFM2vLItbAuvwSMsedKJS5Tw7Aa2pGDALQKL0AGnKE3ezVrBBzJA+9INd8GTFOC3oX/EeCpI4CSKlc7LijfauiDVtJ5gfqR0gf1TKLcWfSUe7mIIvXzzvg0UY"
],
"0/62/5": 1,
"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": 4,
"0/63/3": 3,
"0/63/65532": 0,
"0/63/65533": 2,
"0/63/65528": [2, 5],
"0/63/65529": [0, 1, 3, 4],
"0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"1/3/0": 0,
"1/3/1": 0,
"1/3/65532": 0,
"1/3/65533": 2,
"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": 3,
"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": [
{
"0": 66,
"1": 1
}
],
"1/29/1": [3, 4, 29, 129],
"1/29/2": [],
"1/29/3": [],
"1/29/65532": 0,
"1/29/65533": 2,
"1/29/65528": [],
"1/29/65529": [],
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"1/129/0": 0,
"1/129/1": 0,
"1/129/2": 0,
"1/129/3": null,
"1/129/4": 0,
"1/129/5": 0,
"1/129/6": 0,
"1/129/7": 0,
"1/129/8": 100,
"1/129/9": 0,
"1/129/10": 0,
"1/129/65532": 0,
"1/129/65533": 1,
"1/129/65528": [],
"1/129/65529": [0, 1],
"1/129/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533
]
},
"attribute_subscriptions": []
}

View File

@ -0,0 +1,131 @@
"""Test Matter valve."""
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 (
set_node_attribute,
setup_integration_with_node_fixture,
trigger_subscription_callback,
)
@pytest.fixture(name="valve_node")
async def valve_node_fixture(
hass: HomeAssistant, matter_client: MagicMock
) -> MatterNode:
"""Fixture for a valve node."""
return await setup_integration_with_node_fixture(hass, "valve", matter_client)
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_valve(
hass: HomeAssistant,
matter_client: MagicMock,
valve_node: MatterNode,
) -> None:
"""Test valve entity is created for a Matter ValveConfigurationAndControl Cluster."""
entity_id = "valve.valve_valve"
state = hass.states.get(entity_id)
assert state
assert state.state == "closed"
assert state.attributes["friendly_name"] == "Valve Valve"
# test close_valve action
await hass.services.async_call(
"valve",
"close_valve",
{
"entity_id": entity_id,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=valve_node.node_id,
endpoint_id=1,
command=clusters.ValveConfigurationAndControl.Commands.Close(),
)
matter_client.send_device_command.reset_mock()
# test open_valve action
await hass.services.async_call(
"valve",
"open_valve",
{
"entity_id": entity_id,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=valve_node.node_id,
endpoint_id=1,
command=clusters.ValveConfigurationAndControl.Commands.Open(),
)
matter_client.send_device_command.reset_mock()
# set changing state to 'opening'
set_node_attribute(valve_node, 1, 129, 4, 2)
set_node_attribute(valve_node, 1, 129, 5, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "opening"
# set changing state to 'closing'
set_node_attribute(valve_node, 1, 129, 4, 2)
set_node_attribute(valve_node, 1, 129, 5, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "closing"
# set changing state to 'open'
set_node_attribute(valve_node, 1, 129, 4, 1)
set_node_attribute(valve_node, 1, 129, 5, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "open"
# add support for setting position by updating the featuremap
set_node_attribute(valve_node, 1, 129, 65532, 2)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.attributes["current_position"] == 0
# update current position
set_node_attribute(valve_node, 1, 129, 6, 50)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.attributes["current_position"] == 50
# test set_position action
await hass.services.async_call(
"valve",
"set_valve_position",
{
"entity_id": entity_id,
"position": 100,
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=valve_node.node_id,
endpoint_id=1,
command=clusters.ValveConfigurationAndControl.Commands.Open(targetLevel=100),
)
matter_client.send_device_command.reset_mock()