Catch and convert MatterError when sending device commands (#136635)

This commit is contained in:
Marcel van der Veldt 2025-01-27 19:36:16 +01:00 committed by GitHub
parent 3984565084
commit 557b9d88b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 170 additions and 176 deletions

View File

@ -49,11 +49,7 @@ class MatterCommandButton(MatterEntity, ButtonEntity):
"""Handle the button press leveraging a Matter command."""
if TYPE_CHECKING:
assert self.entity_description.command is not None
await self.matter_client.send_device_command(
node_id=self._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=self.entity_description.command(),
)
await self.send_device_command(self.entity_description.command())
# Discovery schema(s) to map Matter Attributes to HA entities

View File

@ -212,57 +212,45 @@ class MatterClimate(MatterEntity, ClimateEntity):
matter_attribute = (
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
)
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
matter_attribute,
),
await self.write_attribute(
value=int(target_temperature * TEMPERATURE_SCALING_FACTOR),
matter_attribute=matter_attribute,
)
return
if target_temperature_low is not None:
# multi setpoint control - low setpoint (heat)
if self.target_temperature_low != target_temperature_low:
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
),
await self.write_attribute(
value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR),
matter_attribute=clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
)
if target_temperature_high is not None:
# multi setpoint control - high setpoint (cool)
if self.target_temperature_high != target_temperature_high:
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
),
await self.write_attribute(
value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR),
matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
system_mode_path = create_attribute_path_from_attribute(
endpoint_id=self._endpoint.endpoint_id,
attribute=clusters.Thermostat.Attributes.SystemMode,
)
system_mode_value = HVAC_SYSTEM_MODE_MAP.get(hvac_mode)
if system_mode_value is None:
raise ValueError(f"Unsupported hvac mode {hvac_mode} in Matter")
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=system_mode_path,
await self.write_attribute(
value=system_mode_value,
matter_attribute=clusters.Thermostat.Attributes.SystemMode,
)
# we need to optimistically update the attribute's value here
# to prevent a race condition when adjusting the mode and temperature
# in the same call
system_mode_path = create_attribute_path_from_attribute(
endpoint_id=self._endpoint.endpoint_id,
attribute=clusters.Thermostat.Attributes.SystemMode,
)
self._endpoint.set_attribute_value(system_mode_path, system_mode_value)
self._update_from_device()

View File

@ -102,14 +102,6 @@ class MatterCover(MatterEntity, CoverEntity):
clusters.WindowCovering.Commands.GoToTiltPercentage((100 - position) * 100)
)
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."""

View File

@ -2,18 +2,24 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import functools
import logging
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Concatenate, cast
from chip.clusters import Objects as clusters
from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue
from matter_server.common.helpers.util import create_attribute_path
from chip.clusters.Objects import ClusterAttributeDescriptor, ClusterCommand, NullValue
from matter_server.common.errors import MatterError
from matter_server.common.helpers.util import (
create_attribute_path,
create_attribute_path_from_attribute,
)
from matter_server.common.models import EventType, ServerInfoMessage
from propcache.api import cached_property
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
import homeassistant.helpers.entity_registry as er
@ -31,6 +37,23 @@ if TYPE_CHECKING:
LOGGER = logging.getLogger(__name__)
def catch_matter_error[_R, **P](
func: Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]]:
"""Catch Matter errors and convert to Home Assistant error."""
@functools.wraps(func)
async def wrapper(self: MatterEntity, *args: P.args, **kwargs: P.kwargs) -> _R:
"""Catch Matter errors and convert to Home Assistant error."""
try:
return await func(self, *args, **kwargs)
except MatterError as err:
error_msg = str(err) or err.__class__.__name__
raise HomeAssistantError(error_msg) from err
return wrapper
@dataclass(frozen=True)
class MatterEntityDescription(EntityDescription):
"""Describe the Matter entity."""
@ -218,3 +241,38 @@ class MatterEntity(Entity):
return create_attribute_path(
self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id
)
@catch_matter_error
async def send_device_command(
self,
command: ClusterCommand,
**kwargs: Any,
) -> None:
"""Send device command on the primary attribute's endpoint."""
await self.matter_client.send_device_command(
node_id=self._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=command,
**kwargs,
)
@catch_matter_error
async def write_attribute(
self,
value: Any,
matter_attribute: type[ClusterAttributeDescriptor] | None = None,
) -> Any:
"""Write an attribute(value) on the primary endpoint.
If matter_attribute is not provided, the primary attribute of the entity is used.
"""
if matter_attribute is None:
matter_attribute = self._entity_info.primary_attribute
return await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
matter_attribute,
),
value=value,
)

View File

@ -5,7 +5,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from chip.clusters import Objects as clusters
from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.fan import (
DIRECTION_FORWARD,
@ -97,24 +96,16 @@ class MatterFan(MatterEntity, FanEntity):
# clear the wind setting if its currently set
if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]:
await self._set_wind_mode(None)
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
clusters.FanControl.Attributes.FanMode,
),
await self.write_attribute(
value=clusters.FanControl.Enums.FanModeEnum.kOff,
matter_attribute=clusters.FanControl.Attributes.FanMode,
)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
clusters.FanControl.Attributes.PercentSetting,
),
await self.write_attribute(
value=percentage,
matter_attribute=clusters.FanControl.Attributes.PercentSetting,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
@ -128,41 +119,33 @@ class MatterFan(MatterEntity, FanEntity):
if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]:
await self._set_wind_mode(None)
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
clusters.FanControl.Attributes.FanMode,
),
await self.write_attribute(
value=FAN_MODE_MAP[preset_mode],
matter_attribute=clusters.FanControl.Attributes.FanMode,
)
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
clusters.FanControl.Attributes.RockSetting,
await self.write_attribute(
value=(
self.get_matter_attribute_value(
clusters.FanControl.Attributes.RockSupport
)
if oscillating
else 0
),
value=self.get_matter_attribute_value(
clusters.FanControl.Attributes.RockSupport
)
if oscillating
else 0,
matter_attribute=clusters.FanControl.Attributes.RockSetting,
)
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
clusters.FanControl.Attributes.AirflowDirection,
await self.write_attribute(
value=(
clusters.FanControl.Enums.AirflowDirectionEnum.kReverse
if direction == DIRECTION_REVERSE
else clusters.FanControl.Enums.AirflowDirectionEnum.kForward
),
value=clusters.FanControl.Enums.AirflowDirectionEnum.kReverse
if direction == DIRECTION_REVERSE
else clusters.FanControl.Enums.AirflowDirectionEnum.kForward,
matter_attribute=clusters.FanControl.Attributes.AirflowDirection,
)
async def _set_wind_mode(self, wind_mode: str | None) -> None:
@ -173,13 +156,9 @@ class MatterFan(MatterEntity, FanEntity):
wind_setting = WindBitmap.kSleepWind
else:
wind_setting = 0
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
clusters.FanControl.Attributes.WindSetting,
),
await self.write_attribute(
value=wind_setting,
matter_attribute=clusters.FanControl.Attributes.WindSetting,
)
@callback

View File

@ -282,14 +282,6 @@ class MatterLight(MatterEntity, LightEntity):
return ha_color_mode
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,
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn light on."""

View File

@ -62,19 +62,6 @@ class MatterLock(MatterEntity, LockEntity):
return None
async def send_device_command(
self,
command: clusters.ClusterCommand,
timed_request_timeout_ms: int = 1000,
) -> 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,
timed_request_timeout_ms=timed_request_timeout_ms,
)
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock with pin if needed."""
if not self._attr_is_locked:
@ -89,7 +76,8 @@ class MatterLock(MatterEntity, LockEntity):
code: str | None = kwargs.get(ATTR_CODE)
code_bytes = code.encode() if code else None
await self.send_device_command(
command=clusters.DoorLock.Commands.LockDoor(code_bytes)
command=clusters.DoorLock.Commands.LockDoor(code_bytes),
timed_request_timeout_ms=1000,
)
async def async_unlock(self, **kwargs: Any) -> None:
@ -110,11 +98,13 @@ class MatterLock(MatterEntity, LockEntity):
# the unlock command should unbolt only on the unlock command
# and unlatch on the HA 'open' command.
await self.send_device_command(
command=clusters.DoorLock.Commands.UnboltDoor(code_bytes)
command=clusters.DoorLock.Commands.UnboltDoor(code_bytes),
timed_request_timeout_ms=1000,
)
else:
await self.send_device_command(
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes)
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
timed_request_timeout_ms=1000,
)
async def async_open(self, **kwargs: Any) -> None:
@ -130,7 +120,8 @@ class MatterLock(MatterEntity, LockEntity):
code: str | None = kwargs.get(ATTR_CODE)
code_bytes = code.encode() if code else None
await self.send_device_command(
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes)
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
timed_request_timeout_ms=1000,
)
@callback

View File

@ -6,7 +6,6 @@ from dataclasses import dataclass
from chip.clusters import Objects as clusters
from matter_server.common import custom_clusters
from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.number import (
NumberDeviceClass,
@ -52,16 +51,10 @@ class MatterNumber(MatterEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
matter_attribute = self._entity_info.primary_attribute
sendvalue = int(value)
if value_convert := self.entity_description.ha_to_native_value:
sendvalue = value_convert(value)
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
matter_attribute,
),
await self.write_attribute(
value=sendvalue,
)

View File

@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, cast
from chip.clusters import Objects as clusters
from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
from chip.clusters.Types import Nullable
from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
@ -70,11 +69,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity):
value_convert = self.entity_description.ha_to_native_value
if TYPE_CHECKING:
assert value_convert is not None
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id, self._entity_info.primary_attribute
),
await self.write_attribute(
value=value_convert(option),
)
@ -101,10 +96,8 @@ class MatterModeSelectEntity(MatterAttributeSelectEntity):
for mode in cluster.supportedModes:
if mode.label != option:
continue
await self.matter_client.send_device_command(
node_id=self._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=cluster.Commands.ChangeToMode(newMode=mode.mode),
await self.send_device_command(
cluster.Commands.ChangeToMode(newMode=mode.mode),
)
break
@ -132,10 +125,8 @@ class MatterListSelectEntity(MatterEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
option_id = self._attr_options.index(option)
await self.matter_client.send_device_command(
node_id=self._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=self.entity_description.command(option_id),
await self.send_device_command(
self.entity_description.command(option_id),
)
@callback

View File

@ -7,7 +7,6 @@ from typing import Any
from chip.clusters import Objects as clusters
from matter_server.client.models import device_types
from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.switch import (
SwitchDeviceClass,
@ -41,18 +40,14 @@ class MatterSwitch(MatterEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn switch on."""
await self.matter_client.send_device_command(
node_id=self._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=clusters.OnOff.Commands.On(),
await self.send_device_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._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=clusters.OnOff.Commands.Off(),
await self.send_device_command(
clusters.OnOff.Commands.Off(),
)
@callback
@ -77,15 +72,9 @@ class MatterNumericSwitch(MatterSwitch):
async def _async_set_native_value(self, value: bool) -> None:
"""Update the current value."""
matter_attribute = self._entity_info.primary_attribute
if value_convert := self.entity_description.ha_to_native_value:
send_value = value_convert(value)
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
matter_attribute,
),
await self.write_attribute(
value=send_value,
)

View File

@ -69,15 +69,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
await self._send_device_command(clusters.OperationalState.Commands.Stop())
await self.send_device_command(clusters.OperationalState.Commands.Stop())
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
await self._send_device_command(clusters.RvcOperationalState.Commands.GoHome())
await self.send_device_command(clusters.RvcOperationalState.Commands.GoHome())
async def async_locate(self, **kwargs: Any) -> None:
"""Locate the vacuum cleaner."""
await self._send_device_command(clusters.Identify.Commands.Identify())
await self.send_device_command(clusters.Identify.Commands.Identify())
async def async_start(self) -> None:
"""Start or resume the cleaning task."""
@ -87,26 +87,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
clusters.RvcOperationalState.Commands.Resume.command_id
in self._last_accepted_commands
):
await self._send_device_command(
await self.send_device_command(
clusters.RvcOperationalState.Commands.Resume()
)
else:
await self._send_device_command(clusters.OperationalState.Commands.Start())
await self.send_device_command(clusters.OperationalState.Commands.Start())
async def async_pause(self) -> None:
"""Pause the cleaning task."""
await self._send_device_command(clusters.OperationalState.Commands.Pause())
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,
)
await self.send_device_command(clusters.OperationalState.Commands.Pause())
@callback
def _update_from_device(self) -> None:

View File

@ -42,17 +42,6 @@ class MatterValve(MatterEntity, ValveEntity):
entity_description: ValveEntityDescription
_platform_translation_key = "valve"
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())

View File

@ -4,12 +4,14 @@ from unittest.mock import MagicMock, call
from matter_server.client.models.node import MatterNode
from matter_server.common import custom_clusters
from matter_server.common.errors import MatterError
from matter_server.common.helpers.util import create_attribute_path_from_attribute
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .common import (
@ -97,3 +99,25 @@ async def test_eve_weather_sensor_altitude(
),
value=500,
)
@pytest.mark.parametrize("node_fixture", ["dimmable_light"])
async def test_matter_exception_on_write_attribute(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test if a MatterError gets converted to HomeAssistantError by using a dimmable_light fixture."""
state = hass.states.get("number.mock_dimmable_light_on_level")
assert state
matter_client.write_attribute.side_effect = MatterError("Boom")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"number",
"set_value",
{
"entity_id": "number.mock_dimmable_light_on_level",
"value": 500,
},
blocking=True,
)

View File

@ -4,12 +4,14 @@ from unittest.mock import MagicMock, call
from chip.clusters import Objects as clusters
from matter_server.client.models.node import MatterNode
from matter_server.common.errors import MatterError
from matter_server.common.helpers.util import create_attribute_path_from_attribute
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .common import (
@ -165,3 +167,24 @@ async def test_numeric_switch(
),
value=0,
)
@pytest.mark.parametrize("node_fixture", ["on_off_plugin_unit"])
async def test_matter_exception_on_command(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test if a MatterError gets converted to HomeAssistantError by using a switch fixture."""
state = hass.states.get("switch.mock_onoffpluginunit")
assert state
matter_client.send_device_command.side_effect = MatterError("Boom")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"switch",
"turn_on",
{
"entity_id": "switch.mock_onoffpluginunit",
},
blocking=True,
)