diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 88ea084d25f..4e6705cbe09 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -3,7 +3,7 @@ import asyncio from asyncio import TimeoutError as AsyncIOTimeoutError from aiohttp import ClientError, ClientTimeout -from bond_api import Bond +from bond_api import Bond, BPUPSubscriptions, start_bpup from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST @@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import SLOW_UPDATE_WARNING -from .const import BRIDGE_MAKE, DOMAIN +from .const import BPUP_STOP, BPUP_SUBS, BRIDGE_MAKE, DOMAIN, HUB from .utils import BondHub PLATFORMS = ["cover", "fan", "light", "switch"] @@ -38,7 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): except (ClientError, AsyncIOTimeoutError, OSError) as error: raise ConfigEntryNotReady from error - hass.data[DOMAIN][config_entry_id] = hub + bpup_subs = BPUPSubscriptions() + stop_bpup = await start_bpup(host, bpup_subs) + + hass.data[DOMAIN][entry.entry_id] = { + HUB: hub, + BPUP_SUBS: bpup_subs, + BPUP_STOP: stop_bpup, + } if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=hub.bond_id) @@ -74,6 +81,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) + data = hass.data[DOMAIN][entry.entry_id] + if BPUP_STOP in data: + data[BPUP_STOP]() + if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 6666cd57ca3..2004da0c81e 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -55,7 +55,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Bond.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH _discovered: dict = None diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 3031c159b0f..818288a5764 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -5,3 +5,8 @@ BRIDGE_MAKE = "Olibra" DOMAIN = "bond" CONF_BOND_ID: str = "bond_id" + + +HUB = "hub" +BPUP_SUBS = "bpup_subs" +BPUP_STOP = "bpup_stop" diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index dc0fc6d500c..6b3c8d6bc02 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -1,14 +1,14 @@ """Support for Bond covers.""" from typing import Any, Callable, List, Optional -from bond_api import Action, DeviceType +from bond_api import Action, BPUPSubscriptions, DeviceType from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity from .utils import BondDevice, BondHub @@ -19,10 +19,12 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond cover devices.""" - hub: BondHub = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = data[HUB] + bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] covers = [ - BondCover(hub, device) + BondCover(hub, device, bpup_subs) for device in hub.devices if device.type == DeviceType.MOTORIZED_SHADES ] @@ -33,9 +35,9 @@ async def async_setup_entry( class BondCover(BondEntity, CoverEntity): """Representation of a Bond cover.""" - def __init__(self, hub: BondHub, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): """Create HA entity representing Bond cover.""" - super().__init__(hub, device) + super().__init__(hub, device, bpup_subs) self._closed: Optional[bool] = None diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 2819182c9b5..769794a31e8 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -1,37 +1,51 @@ """An abstract class common to all Bond entities.""" from abc import abstractmethod -from asyncio import TimeoutError as AsyncIOTimeoutError +from asyncio import Lock, TimeoutError as AsyncIOTimeoutError +from datetime import timedelta import logging from typing import Any, Dict, Optional from aiohttp import ClientError +from bond_api import BPUPSubscriptions from homeassistant.const import ATTR_NAME +from homeassistant.core import callback from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval from .const import DOMAIN from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) +_FALLBACK_SCAN_INTERVAL = timedelta(seconds=10) + class BondEntity(Entity): """Generic Bond entity encapsulating common features of any Bond controlled device.""" def __init__( - self, hub: BondHub, device: BondDevice, sub_device: Optional[str] = None + self, + hub: BondHub, + device: BondDevice, + bpup_subs: BPUPSubscriptions, + sub_device: Optional[str] = None, ): """Initialize entity with API and device info.""" self._hub = hub self._device = device + self._device_id = device.device_id self._sub_device = sub_device self._available = True + self._bpup_subs = bpup_subs + self._update_lock = None + self._initialized = False @property def unique_id(self) -> Optional[str]: """Get unique ID for the entity.""" hub_id = self._hub.bond_id - device_id = self._device.device_id + device_id = self._device_id sub_device_id: str = f"_{self._sub_device}" if self._sub_device else "" return f"{hub_id}_{device_id}{sub_device_id}" @@ -40,13 +54,18 @@ class BondEntity(Entity): """Get entity name.""" return self._device.name + @property + def should_poll(self): + """No polling needed.""" + return False + @property def device_info(self) -> Optional[Dict[str, Any]]: """Get a an HA device representing this Bond controlled device.""" device_info = { ATTR_NAME: self.name, "manufacturer": self._hub.make, - "identifiers": {(DOMAIN, self._hub.bond_id, self._device.device_id)}, + "identifiers": {(DOMAIN, self._hub.bond_id, self._device_id)}, "via_device": (DOMAIN, self._hub.bond_id), } if not self._hub.is_bridge: @@ -75,8 +94,29 @@ class BondEntity(Entity): async def async_update(self): """Fetch assumed state of the cover from the hub using API.""" + await self._async_update_from_api() + + async def _async_update_if_bpup_not_alive(self, *_): + """Fetch via the API if BPUP is not alive.""" + if self._bpup_subs.alive and self._initialized: + return + + if self._update_lock.locked(): + _LOGGER.warning( + "Updating %s took longer than the scheduled update interval %s", + self.entity_id, + _FALLBACK_SCAN_INTERVAL, + ) + return + + async with self._update_lock: + await self._async_update_from_api() + self.async_write_ha_state() + + async def _async_update_from_api(self): + """Fetch via the API.""" try: - state: dict = await self._hub.bond.device_state(self._device.device_id) + state: dict = await self._hub.bond.device_state(self._device_id) except (ClientError, AsyncIOTimeoutError, OSError) as error: if self._available: _LOGGER.warning( @@ -84,12 +124,42 @@ class BondEntity(Entity): ) self._available = False else: - _LOGGER.debug("Device state for %s is:\n%s", self.entity_id, state) - if not self._available: - _LOGGER.info("Entity %s has come back", self.entity_id) - self._available = True - self._apply_state(state) + self._async_state_callback(state) @abstractmethod def _apply_state(self, state: dict): raise NotImplementedError + + @callback + def _async_state_callback(self, state): + """Process a state change.""" + self._initialized = True + if not self._available: + _LOGGER.info("Entity %s has come back", self.entity_id) + self._available = True + _LOGGER.debug( + "Device state for %s (%s) is:\n%s", self.name, self.entity_id, state + ) + self._apply_state(state) + + @callback + def _async_bpup_callback(self, state): + """Process a state change from BPUP.""" + self._async_state_callback(state) + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to BPUP and start polling.""" + await super().async_added_to_hass() + self._update_lock = Lock() + self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback) + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_update_if_bpup_not_alive, _FALLBACK_SCAN_INTERVAL + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from BPUP data on remove.""" + await super().async_will_remove_from_hass() + self._bpup_subs.unsubscribe(self._device_id, self._async_bpup_callback) diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 18eeb912ed8..9b70195db5d 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -3,7 +3,7 @@ import logging import math from typing import Any, Callable, List, Optional, Tuple -from bond_api import Action, DeviceType, Direction +from bond_api import Action, BPUPSubscriptions, DeviceType, Direction from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -20,7 +20,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DOMAIN +from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity from .utils import BondDevice, BondHub @@ -33,10 +33,14 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond fan devices.""" - hub: BondHub = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = data[HUB] + bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] fans = [ - BondFan(hub, device) for device in hub.devices if DeviceType.is_fan(device.type) + BondFan(hub, device, bpup_subs) + for device in hub.devices + if DeviceType.is_fan(device.type) ] async_add_entities(fans, True) @@ -45,9 +49,9 @@ async def async_setup_entry( class BondFan(BondEntity, FanEntity): """Representation of a Bond fan.""" - def __init__(self, hub: BondHub, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): """Create HA entity representing Bond fan.""" - super().__init__(hub, device) + super().__init__(hub, device, bpup_subs) self._power: Optional[bool] = None self._speed: Optional[int] = None diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index c0809a0aee7..8d0dfe85246 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -2,7 +2,7 @@ import logging from typing import Any, Callable, List, Optional -from bond_api import Action, DeviceType +from bond_api import Action, BPUPSubscriptions, DeviceType from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from . import BondHub -from .const import DOMAIN +from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity from .utils import BondDevice @@ -27,28 +27,30 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond light devices.""" - hub: BondHub = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = data[HUB] + bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] fan_lights: List[Entity] = [ - BondLight(hub, device) + BondLight(hub, device, bpup_subs) for device in hub.devices if DeviceType.is_fan(device.type) and device.supports_light() ] fireplaces: List[Entity] = [ - BondFireplace(hub, device) + BondFireplace(hub, device, bpup_subs) for device in hub.devices if DeviceType.is_fireplace(device.type) ] fp_lights: List[Entity] = [ - BondLight(hub, device, "light") + BondLight(hub, device, bpup_subs, "light") for device in hub.devices if DeviceType.is_fireplace(device.type) and device.supports_light() ] lights: List[Entity] = [ - BondLight(hub, device) + BondLight(hub, device, bpup_subs) for device in hub.devices if DeviceType.is_light(device.type) ] @@ -60,10 +62,14 @@ class BondLight(BondEntity, LightEntity): """Representation of a Bond light.""" def __init__( - self, hub: BondHub, device: BondDevice, sub_device: Optional[str] = None + self, + hub: BondHub, + device: BondDevice, + bpup_subs: BPUPSubscriptions, + sub_device: Optional[str] = None, ): """Create HA entity representing Bond fan.""" - super().__init__(hub, device, sub_device) + super().__init__(hub, device, bpup_subs, sub_device) self._brightness: Optional[int] = None self._light: Optional[int] = None @@ -110,9 +116,9 @@ class BondLight(BondEntity, LightEntity): class BondFireplace(BondEntity, LightEntity): """Representation of a Bond-controlled fireplace.""" - def __init__(self, hub: BondHub, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): """Create HA entity representing Bond fireplace.""" - super().__init__(hub, device) + super().__init__(hub, device, bpup_subs) self._power: Optional[bool] = None # Bond flame level, 0-100 diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 3f62403dba7..e1ec5e5dd46 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,7 +3,7 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-api==0.1.8"], + "requirements": ["bond-api==0.1.9"], "zeroconf": ["_bond._tcp.local."], "codeowners": ["@prystupa"], "quality_scale": "platinum" diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index d2f1797225d..8319d31c714 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -1,14 +1,14 @@ """Support for Bond generic devices.""" from typing import Any, Callable, List, Optional -from bond_api import Action, DeviceType +from bond_api import Action, BPUPSubscriptions, DeviceType from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity from .utils import BondDevice, BondHub @@ -19,10 +19,12 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond generic devices.""" - hub: BondHub = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = data[HUB] + bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] switches = [ - BondSwitch(hub, device) + BondSwitch(hub, device, bpup_subs) for device in hub.devices if DeviceType.is_generic(device.type) ] @@ -33,9 +35,9 @@ async def async_setup_entry( class BondSwitch(BondEntity, SwitchEntity): """Representation of a Bond generic device.""" - def __init__(self, hub: BondHub, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions): """Create HA entity representing Bond generic device (switch).""" - super().__init__(hub, device) + super().__init__(hub, device, bpup_subs) self._power: Optional[bool] = None diff --git a/requirements_all.txt b/requirements_all.txt index a28eee2096e..e785542768f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -367,7 +367,7 @@ blockchain==1.4.4 # bme680==1.0.5 # homeassistant.components.bond -bond-api==0.1.8 +bond-api==0.1.9 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2f022e9ffe..fd6d5800461 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -201,7 +201,7 @@ blebox_uniapi==1.3.2 blinkpy==0.16.4 # homeassistant.components.bond -bond-api==0.1.8 +bond-api==0.1.9 # homeassistant.components.braviatv bravia-tv==1.0.8 diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 9aaaf9a249d..ba4d10c8892 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -3,7 +3,7 @@ from asyncio import TimeoutError as AsyncIOTimeoutError from contextlib import nullcontext from datetime import timedelta from typing import Any, Dict, Optional -from unittest.mock import patch +from unittest.mock import MagicMock, patch from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN @@ -33,9 +33,11 @@ async def setup_bond_entity( """Set up Bond entity.""" config_entry.add_to_hass(hass) - with patch_bond_version(enabled=patch_version), patch_bond_device_ids( - enabled=patch_device_ids - ), patch_setup_entry("cover", enabled=patch_platforms), patch_setup_entry( + with patch_start_bpup(), patch_bond_version( + enabled=patch_version + ), patch_bond_device_ids(enabled=patch_device_ids), patch_setup_entry( + "cover", enabled=patch_platforms + ), patch_setup_entry( "fan", enabled=patch_platforms ), patch_setup_entry( "light", enabled=patch_platforms @@ -65,7 +67,7 @@ async def setup_platform( with patch("homeassistant.components.bond.PLATFORMS", [platform]): with patch_bond_version(return_value=bond_version), patch_bond_device_ids( return_value=[bond_device_id] - ), patch_bond_device( + ), patch_start_bpup(), patch_bond_device( return_value=discovered_device ), patch_bond_device_properties( return_value=props @@ -118,6 +120,14 @@ def patch_bond_device(return_value=None): ) +def patch_start_bpup(): + """Patch start_bpup.""" + return patch( + "homeassistant.components.bond.start_bpup", + return_value=MagicMock(), + ) + + def patch_bond_action(): """Patch Bond API action endpoint.""" return patch("homeassistant.components.bond.Bond.action") diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index e2bb6314126..4dc7ae5c8d4 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -20,6 +20,7 @@ from .common import ( patch_bond_device_state, patch_bond_version, patch_setup_entry, + patch_start_bpup, setup_bond_entity, ) @@ -141,7 +142,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant): "target": "test-model", "fw_ver": "test-version", } - ), patch_bond_device_ids( + ), patch_start_bpup(), patch_bond_device_ids( return_value=["bond-device-id", "device_id"] ), patch_bond_device( return_value={