Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wrap most ZHA exceptions in HomeAssistantError #98421

Merged
merged 11 commits into from
Aug 28, 2023
18 changes: 4 additions & 14 deletions homeassistant/components/zha/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@
import logging
from typing import TYPE_CHECKING, Any, Self

import zigpy.exceptions
from zigpy.zcl.foundation import Status

from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
Expand Down Expand Up @@ -134,17 +131,10 @@ def __init__(

async def async_press(self) -> None:
"""Write attribute with defined value."""
try:
result = await self._cluster_handler.cluster.write_attributes(
{self._attribute_name: self._attribute_value}
)
except zigpy.exceptions.ZigbeeException as ex:
self.error("Could not set value: %s", ex)
return
if not isinstance(result, Exception) and all(
record.status == Status.SUCCESS for record in result[0]
):
self.async_write_ha_state()
await self._cluster_handler.write_attributes_safe(
{self._attribute_name: self._attribute_value}
)
self.async_write_ha_state()


@CONFIG_DIAGNOSTIC_MATCH(
Expand Down
103 changes: 45 additions & 58 deletions homeassistant/components/zha/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,15 +416,12 @@
if self.preset_mode not in (
preset_mode,
PRESET_NONE,
) and not await self.async_preset_handler(self.preset_mode, enable=False):
self.debug("Couldn't turn off '%s' preset", self.preset_mode)
return

if preset_mode != PRESET_NONE and not await self.async_preset_handler(
preset_mode, enable=True
):
self.debug("Couldn't turn on '%s' preset", preset_mode)
return
await self.async_preset_handler(self.preset_mode, enable=False)

if preset_mode != PRESET_NONE:
await self.async_preset_handler(preset_mode, enable=True)

self._preset = preset_mode
self.async_write_ha_state()

Expand All @@ -438,30 +435,29 @@
if hvac_mode is not None:
await self.async_set_hvac_mode(hvac_mode)

thrm = self._thrm
is_away = self.preset_mode == PRESET_AWAY

if self.hvac_mode == HVACMode.HEAT_COOL:
success = True
if low_temp is not None:
low_temp = int(low_temp * ZCL_TEMP)
success = success and await thrm.async_set_heating_setpoint(
low_temp, self.preset_mode == PRESET_AWAY
await self._thrm.async_set_heating_setpoint(
temperature=int(low_temp * ZCL_TEMP),
is_away=is_away,
)
self.debug("Setting heating %s setpoint: %s", low_temp, success)
if high_temp is not None:
high_temp = int(high_temp * ZCL_TEMP)
success = success and await thrm.async_set_cooling_setpoint(
high_temp, self.preset_mode == PRESET_AWAY
await self._thrm.async_set_cooling_setpoint(
temperature=int(high_temp * ZCL_TEMP),
is_away=is_away,
)
self.debug("Setting cooling %s setpoint: %s", low_temp, success)
elif temp is not None:
temp = int(temp * ZCL_TEMP)
if self.hvac_mode == HVACMode.COOL:
success = await thrm.async_set_cooling_setpoint(
temp, self.preset_mode == PRESET_AWAY
await self._thrm.async_set_cooling_setpoint(
temperature=int(temp * ZCL_TEMP),
is_away=is_away,
)
elif self.hvac_mode == HVACMode.HEAT:
success = await thrm.async_set_heating_setpoint(
temp, self.preset_mode == PRESET_AWAY
await self._thrm.async_set_heating_setpoint(
temperature=int(temp * ZCL_TEMP),
is_away=is_away,
)
else:
self.debug("Not setting temperature for '%s' mode", self.hvac_mode)
Expand All @@ -470,14 +466,13 @@
self.debug("incorrect %s setting for '%s' mode", kwargs, self.hvac_mode)
return

if success:
self.async_write_ha_state()
self.async_write_ha_state()

async def async_preset_handler(self, preset: str, enable: bool = False) -> bool:
async def async_preset_handler(self, preset: str, enable: bool = False) -> None:
"""Set the preset mode via handler."""

handler = getattr(self, f"async_preset_handler_{preset}")
return await handler(enable)
await handler(enable)


@MULTI_MATCH(
Expand Down Expand Up @@ -529,7 +524,7 @@

self.debug("Updating time: %s", secs_2k)
self._manufacturer_ch.cluster.create_catching_task(
self._manufacturer_ch.cluster.write_attributes(
self._manufacturer_ch.write_attributes_safe(
{"secs_since_2k": secs_2k}, manufacturer=self.manufacturer
)
)
Expand All @@ -544,16 +539,13 @@
)
self._async_update_time()

async def async_preset_handler_away(self, is_away: bool = False) -> bool:
async def async_preset_handler_away(self, is_away: bool = False) -> None:
"""Set occupancy."""
mfg_code = self._zha_device.manufacturer_code
res = await self._thrm.write_attributes(
await self._thrm.write_attributes_safe(
{"set_occupancy": 0 if is_away else 1}, manufacturer=mfg_code
)

self.debug("set occupancy to %s. Status: %s", 0 if is_away else 1, res)
return res


@MULTI_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
Expand Down Expand Up @@ -635,40 +627,38 @@
self._preset = PRESET_COMPLEX
await super().async_attribute_updated(record)

async def async_preset_handler(self, preset: str, enable: bool = False) -> bool:
async def async_preset_handler(self, preset: str, enable: bool = False) -> None:
"""Set the preset mode."""
mfg_code = self._zha_device.manufacturer_code
if not enable:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 2}, manufacturer=mfg_code
)
if preset == PRESET_AWAY:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 0}, manufacturer=mfg_code
)
if preset == PRESET_SCHEDULE:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 1}, manufacturer=mfg_code
)
if preset == PRESET_COMFORT:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 3}, manufacturer=mfg_code
)
if preset == PRESET_ECO:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 4}, manufacturer=mfg_code
)
if preset == PRESET_BOOST:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 5}, manufacturer=mfg_code
)
if preset == PRESET_COMPLEX:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 6}, manufacturer=mfg_code
)

return False


@STRICT_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
Expand Down Expand Up @@ -714,36 +704,34 @@
self._preset = PRESET_TEMP_MANUAL
await super().async_attribute_updated(record)

async def async_preset_handler(self, preset: str, enable: bool = False) -> bool:
async def async_preset_handler(self, preset: str, enable: bool = False) -> None:
"""Set the preset mode."""
mfg_code = self._zha_device.manufacturer_code
if not enable:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(

Check warning on line 711 in homeassistant/components/zha/climate.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/zha/climate.py#L711

Added line #L711 was not covered by tests
{"operation_preset": 2}, manufacturer=mfg_code
)
if preset == PRESET_AWAY:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 0}, manufacturer=mfg_code
)
if preset == PRESET_SCHEDULE:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 1}, manufacturer=mfg_code
)
if preset == PRESET_ECO:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 4}, manufacturer=mfg_code
)
if preset == PRESET_BOOST:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 5}, manufacturer=mfg_code
)
if preset == PRESET_TEMP_MANUAL:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 7}, manufacturer=mfg_code
)

return False


@MULTI_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
Expand Down Expand Up @@ -809,23 +797,22 @@
self._preset = self.PRESET_FROST
await super().async_attribute_updated(record)

async def async_preset_handler(self, preset: str, enable: bool = False) -> bool:
async def async_preset_handler(self, preset: str, enable: bool = False) -> None:
"""Set the preset mode."""
mfg_code = self._zha_device.manufacturer_code
if not enable:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 1}, manufacturer=mfg_code
)
if preset == PRESET_SCHEDULE:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 0}, manufacturer=mfg_code
)
if preset == self.PRESET_HOLIDAY:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 3}, manufacturer=mfg_code
)
if preset == self.PRESET_FROST:
return await self._thrm.write_attributes(
return await self._thrm.write_attributes_safe(
{"operation_preset": 4}, manufacturer=mfg_code
)
return False
66 changes: 50 additions & 16 deletions homeassistant/components/zha/core/cluster_handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from collections.abc import Awaitable, Callable, Coroutine, Iterator
import contextlib
from enum import Enum
import functools
import logging
Expand Down Expand Up @@ -48,31 +49,39 @@

_LOGGER = logging.getLogger(__name__)
RETRYABLE_REQUEST_DECORATOR = zigpy.util.retryable_request(tries=3)
UNPROXIED_CLUSTER_METHODS = {"general_command"}


_P = ParamSpec("_P")
_FuncType = Callable[_P, Awaitable[Any]]
_ReturnFuncType = Callable[_P, Coroutine[Any, Any, Any]]


@contextlib.contextmanager
def wrap_zigpy_exceptions() -> Iterator[None]:
"""Wrap zigpy exceptions in `HomeAssistantError` exceptions."""
try:
yield
except asyncio.TimeoutError as exc:
raise HomeAssistantError(
"Failed to send request: device did not respond"
) from exc
except zigpy.exceptions.ZigbeeException as exc:
message = "Failed to send request"

if str(exc):
message = f"{message}: {exc}"

raise HomeAssistantError(message) from exc


def retry_request(func: _FuncType[_P]) -> _ReturnFuncType[_P]:
"""Send a request with retries and wrap expected zigpy exceptions."""

@functools.wraps(func)
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
try:
with wrap_zigpy_exceptions():
return await RETRYABLE_REQUEST_DECORATOR(func)(*args, **kwargs)
except asyncio.TimeoutError as exc:
raise HomeAssistantError(
"Failed to send request: device did not respond"
) from exc
except zigpy.exceptions.ZigbeeException as exc:
message = "Failed to send request"

if str(exc):
message = f"{message}: {exc}"

raise HomeAssistantError(message) from exc

return wrapper

Expand Down Expand Up @@ -501,6 +510,26 @@ async def _get_attributes(

get_attributes = functools.partialmethod(_get_attributes, False)

async def write_attributes_safe(
self, attributes: dict[str, Any], manufacturer: int | None = None
) -> None:
"""Wrap `write_attributes` to throw an exception on attribute write failure."""

res = await self.write_attributes(attributes, manufacturer=manufacturer)

for record in res[0]:
if record.status != Status.SUCCESS:
try:
name = self.cluster.attributes[record.attrid].name
value = attributes.get(name, "unknown")
puddly marked this conversation as resolved.
Show resolved Hide resolved
except KeyError:
name = f"0x{record.attrid:04x}"
value = "unknown"

raise HomeAssistantError(
f"Failed to write attribute {name}={value}: {record.status}",
)

def log(self, level, msg, *args, **kwargs):
"""Log a message."""
msg = f"[%s:%s]: {msg}"
Expand All @@ -509,11 +538,16 @@ def log(self, level, msg, *args, **kwargs):

def __getattr__(self, name):
"""Get attribute or a decorated cluster command."""
if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)):
if (
hasattr(self._cluster, name)
and callable(getattr(self._cluster, name))
and name not in UNPROXIED_CLUSTER_METHODS
):
command = getattr(self._cluster, name)
command.__name__ = name
wrapped_command = retry_request(command)
wrapped_command.__name__ = name

return retry_request(command)
return wrapped_command
return self.__getattribute__(name)


Expand Down
Loading
Loading