diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 60c78ee4dbe..50025adbc1a 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -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 diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index d6d314f2844..8c3b9c638f5 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -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): diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 28395bfbe77..b4d22641204 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -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 diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index da73e086a61..a9d55ce593c 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -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" + ) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index f73310bc504..6a8a15fc4c0 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -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" + ) diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 55936e3a11c..b507395dab3 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -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" + ) diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 1cfdf682d38..8a5803d4eee 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -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" + )