Gracefully handle bond API errors and timeouts through available state (#38137)

* Gracefully handle API errors and timeouts through available state

* Gracefully handle API errors and timeouts through available state
This commit is contained in:
Eugene Prystupa 2020-07-24 16:14:47 -04:00 committed by GitHub
parent 21db4a4160
commit 69203b5373
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 109 additions and 11 deletions

View File

@ -1,17 +1,20 @@
"""The Bond integration."""
import asyncio
from aiohttp import ClientTimeout
from bond_api import Bond
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import SLOW_UPDATE_WARNING
from .const import DOMAIN
from .utils import BondHub
PLATFORMS = ["cover", "fan", "light", "switch"]
_API_TIMEOUT = SLOW_UPDATE_WARNING - 1
async def async_setup(hass: HomeAssistant, config: dict):
@ -25,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
host = entry.data[CONF_HOST]
token = entry.data[CONF_ACCESS_TOKEN]
bond = Bond(host=host, token=token)
bond = Bond(host=host, token=token, timeout=ClientTimeout(total=_API_TIMEOUT))
hub = BondHub(bond)
await hub.setup()
hass.data[DOMAIN][entry.entry_id] = hub

View File

@ -1,13 +1,19 @@
"""An abstract class common to all Bond entities."""
from abc import abstractmethod
from asyncio import TimeoutError as AsyncIOTimeoutError
import logging
from typing import Any, Dict, Optional
from aiohttp import ClientError
from homeassistant.const import ATTR_NAME
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .utils import BondDevice, BondHub
_LOGGER = logging.getLogger(__name__)
class BondEntity(Entity):
"""Generic Bond entity encapsulating common features of any Bond controlled device."""
@ -16,6 +22,7 @@ class BondEntity(Entity):
"""Initialize entity with API and device info."""
self._hub = hub
self._device = device
self._available = True
@property
def unique_id(self) -> Optional[str]:
@ -41,10 +48,26 @@ class BondEntity(Entity):
"""Let HA know this entity relies on an assumed state tracked by Bond."""
return True
@property
def available(self) -> bool:
"""Report availability of this entity based on last API call results."""
return self._available
async def async_update(self):
"""Fetch assumed state of the cover from the hub using API."""
state: dict = await self._hub.bond.device_state(self._device.device_id)
self._apply_state(state)
try:
state: dict = await self._hub.bond.device_state(self._device.device_id)
except (ClientError, AsyncIOTimeoutError, OSError) as error:
if self._available:
_LOGGER.warning(
"Entity %s has become unavailable", self.entity_id, exc_info=error
)
self._available = False
else:
if not self._available:
_LOGGER.info("Entity %s has come back", self.entity_id)
self._available = True
self._apply_state(state)
@abstractmethod
def _apply_state(self, state: dict):

View File

@ -1,13 +1,16 @@
"""Common methods used across tests for Bond."""
from asyncio import TimeoutError as AsyncIOTimeoutError
from datetime import timedelta
from typing import Any, Dict
from homeassistant import core
from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, STATE_UNAVAILABLE
from homeassistant.setup import async_setup_component
from homeassistant.util import utcnow
from tests.async_mock import patch
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
MOCK_HUB_VERSION: dict = {"bondid": "test-bond-id"}
@ -74,11 +77,32 @@ def patch_bond_action():
return patch("homeassistant.components.bond.Bond.action")
def patch_bond_device_state(return_value=None):
def patch_bond_device_state(return_value=None, side_effect=None):
"""Patch Bond API device state endpoint."""
if return_value is None:
return_value = {}
return patch(
"homeassistant.components.bond.Bond.device_state", return_value=return_value
"homeassistant.components.bond.Bond.device_state",
return_value=return_value,
side_effect=side_effect,
)
async def help_test_entity_available(
hass: core.HomeAssistant, domain: str, device: Dict[str, Any], entity_id: str
):
"""Run common test to verify available property."""
await setup_platform(hass, domain, device)
assert hass.states.get(entity_id).state != STATE_UNAVAILABLE
with patch_bond_device_state(side_effect=AsyncIOTimeoutError()):
async_fire_time_changed(hass, utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
with patch_bond_device_state(return_value={}):
async_fire_time_changed(hass, utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
assert hass.states.get(entity_id).state != STATE_UNAVAILABLE

View File

@ -15,7 +15,12 @@ from homeassistant.const import (
from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.util import utcnow
from .common import patch_bond_action, patch_bond_device_state, setup_platform
from .common import (
help_test_entity_available,
patch_bond_action,
patch_bond_device_state,
setup_platform,
)
from tests.common import async_fire_time_changed
@ -109,3 +114,10 @@ async def test_update_reports_closed_cover(hass: core.HomeAssistant):
await hass.async_block_till_done()
assert hass.states.get("cover.name_1").state == "closed"
async def test_cover_available(hass: core.HomeAssistant):
"""Tests that available state is updated based on API errors."""
await help_test_entity_available(
hass, COVER_DOMAIN, shades("name-1"), "cover.name_1"
)

View File

@ -18,7 +18,12 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O
from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.util import utcnow
from .common import patch_bond_action, patch_bond_device_state, setup_platform
from .common import (
help_test_entity_available,
patch_bond_action,
patch_bond_device_state,
setup_platform,
)
from tests.common import async_fire_time_changed
@ -192,3 +197,10 @@ async def test_set_fan_direction(hass: core.HomeAssistant):
mock_set_direction.assert_called_once_with(
"test-device-id", Action.set_direction(Direction.FORWARD)
)
async def test_fan_available(hass: core.HomeAssistant):
"""Tests that available state is updated based on API errors."""
await help_test_entity_available(
hass, FAN_DOMAIN, ceiling_fan("name-1"), "fan.name_1"
)

View File

@ -10,7 +10,12 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O
from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.util import utcnow
from .common import patch_bond_action, patch_bond_device_state, setup_platform
from .common import (
help_test_entity_available,
patch_bond_action,
patch_bond_device_state,
setup_platform,
)
from tests.common import async_fire_time_changed
@ -162,3 +167,10 @@ async def test_flame_converted_to_brightness(hass: core.HomeAssistant):
await hass.async_block_till_done()
assert hass.states.get("light.name_1").attributes[ATTR_BRIGHTNESS] == 128
async def test_light_available(hass: core.HomeAssistant):
"""Tests that available state is updated based on API errors."""
await help_test_entity_available(
hass, LIGHT_DOMAIN, ceiling_fan("name-1"), "light.name_1"
)

View File

@ -10,7 +10,12 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O
from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.util import utcnow
from .common import patch_bond_action, patch_bond_device_state, setup_platform
from .common import (
help_test_entity_available,
patch_bond_action,
patch_bond_device_state,
setup_platform,
)
from tests.common import async_fire_time_changed
@ -86,3 +91,10 @@ async def test_update_reports_switch_is_off(hass: core.HomeAssistant):
await hass.async_block_till_done()
assert hass.states.get("switch.name_1").state == "off"
async def test_switch_available(hass: core.HomeAssistant):
"""Tests that available state is updated based on API errors."""
await help_test_entity_available(
hass, SWITCH_DOMAIN, generic_device("name-1"), "switch.name_1"
)