Add BPUP (push updates) support to bond (#45550)

pull/46270/head
J. Nick Koston 2021-02-08 22:43:38 -10:00 committed by GitHub
parent 2fc1c19a45
commit 6a62ebb6a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 163 additions and 52 deletions

View File

@ -3,7 +3,7 @@ import asyncio
from asyncio import TimeoutError as AsyncIOTimeoutError from asyncio import TimeoutError as AsyncIOTimeoutError
from aiohttp import ClientError, ClientTimeout 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.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST 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 import device_registry as dr
from homeassistant.helpers.entity import SLOW_UPDATE_WARNING 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 from .utils import BondHub
PLATFORMS = ["cover", "fan", "light", "switch"] PLATFORMS = ["cover", "fan", "light", "switch"]
@ -38,7 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
except (ClientError, AsyncIOTimeoutError, OSError) as error: except (ClientError, AsyncIOTimeoutError, OSError) as error:
raise ConfigEntryNotReady from 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: if not entry.unique_id:
hass.config_entries.async_update_entry(entry, unique_id=hub.bond_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: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)

View File

@ -55,7 +55,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Bond.""" """Handle a config flow for Bond."""
VERSION = 1 VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
_discovered: dict = None _discovered: dict = None

View File

@ -5,3 +5,8 @@ BRIDGE_MAKE = "Olibra"
DOMAIN = "bond" DOMAIN = "bond"
CONF_BOND_ID: str = "bond_id" CONF_BOND_ID: str = "bond_id"
HUB = "hub"
BPUP_SUBS = "bpup_subs"
BPUP_STOP = "bpup_stop"

View File

@ -1,14 +1,14 @@
"""Support for Bond covers.""" """Support for Bond covers."""
from typing import Any, Callable, List, Optional 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.components.cover import DEVICE_CLASS_SHADE, CoverEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import DOMAIN from .const import BPUP_SUBS, DOMAIN, HUB
from .entity import BondEntity from .entity import BondEntity
from .utils import BondDevice, BondHub from .utils import BondDevice, BondHub
@ -19,10 +19,12 @@ async def async_setup_entry(
async_add_entities: Callable[[List[Entity], bool], None], async_add_entities: Callable[[List[Entity], bool], None],
) -> None: ) -> None:
"""Set up Bond cover devices.""" """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 = [ covers = [
BondCover(hub, device) BondCover(hub, device, bpup_subs)
for device in hub.devices for device in hub.devices
if device.type == DeviceType.MOTORIZED_SHADES if device.type == DeviceType.MOTORIZED_SHADES
] ]
@ -33,9 +35,9 @@ async def async_setup_entry(
class BondCover(BondEntity, CoverEntity): class BondCover(BondEntity, CoverEntity):
"""Representation of a Bond cover.""" """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.""" """Create HA entity representing Bond cover."""
super().__init__(hub, device) super().__init__(hub, device, bpup_subs)
self._closed: Optional[bool] = None self._closed: Optional[bool] = None

View File

@ -1,37 +1,51 @@
"""An abstract class common to all Bond entities.""" """An abstract class common to all Bond entities."""
from abc import abstractmethod from abc import abstractmethod
from asyncio import TimeoutError as AsyncIOTimeoutError from asyncio import Lock, TimeoutError as AsyncIOTimeoutError
from datetime import timedelta
import logging import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from aiohttp import ClientError from aiohttp import ClientError
from bond_api import BPUPSubscriptions
from homeassistant.const import ATTR_NAME from homeassistant.const import ATTR_NAME
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from .const import DOMAIN from .const import DOMAIN
from .utils import BondDevice, BondHub from .utils import BondDevice, BondHub
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_FALLBACK_SCAN_INTERVAL = timedelta(seconds=10)
class BondEntity(Entity): class BondEntity(Entity):
"""Generic Bond entity encapsulating common features of any Bond controlled device.""" """Generic Bond entity encapsulating common features of any Bond controlled device."""
def __init__( 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.""" """Initialize entity with API and device info."""
self._hub = hub self._hub = hub
self._device = device self._device = device
self._device_id = device.device_id
self._sub_device = sub_device self._sub_device = sub_device
self._available = True self._available = True
self._bpup_subs = bpup_subs
self._update_lock = None
self._initialized = False
@property @property
def unique_id(self) -> Optional[str]: def unique_id(self) -> Optional[str]:
"""Get unique ID for the entity.""" """Get unique ID for the entity."""
hub_id = self._hub.bond_id 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 "" sub_device_id: str = f"_{self._sub_device}" if self._sub_device else ""
return f"{hub_id}_{device_id}{sub_device_id}" return f"{hub_id}_{device_id}{sub_device_id}"
@ -40,13 +54,18 @@ class BondEntity(Entity):
"""Get entity name.""" """Get entity name."""
return self._device.name return self._device.name
@property
def should_poll(self):
"""No polling needed."""
return False
@property @property
def device_info(self) -> Optional[Dict[str, Any]]: def device_info(self) -> Optional[Dict[str, Any]]:
"""Get a an HA device representing this Bond controlled device.""" """Get a an HA device representing this Bond controlled device."""
device_info = { device_info = {
ATTR_NAME: self.name, ATTR_NAME: self.name,
"manufacturer": self._hub.make, "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), "via_device": (DOMAIN, self._hub.bond_id),
} }
if not self._hub.is_bridge: if not self._hub.is_bridge:
@ -75,8 +94,29 @@ class BondEntity(Entity):
async def async_update(self): async def async_update(self):
"""Fetch assumed state of the cover from the hub using API.""" """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: 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: except (ClientError, AsyncIOTimeoutError, OSError) as error:
if self._available: if self._available:
_LOGGER.warning( _LOGGER.warning(
@ -84,12 +124,42 @@ class BondEntity(Entity):
) )
self._available = False self._available = False
else: else:
_LOGGER.debug("Device state for %s is:\n%s", self.entity_id, state) self._async_state_callback(state)
if not self._available:
_LOGGER.info("Entity %s has come back", self.entity_id)
self._available = True
self._apply_state(state)
@abstractmethod @abstractmethod
def _apply_state(self, state: dict): def _apply_state(self, state: dict):
raise NotImplementedError 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)

View File

@ -3,7 +3,7 @@ import logging
import math import math
from typing import Any, Callable, List, Optional, Tuple 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 ( from homeassistant.components.fan import (
DIRECTION_FORWARD, DIRECTION_FORWARD,
@ -20,7 +20,7 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage, ranged_value_to_percentage,
) )
from .const import DOMAIN from .const import BPUP_SUBS, DOMAIN, HUB
from .entity import BondEntity from .entity import BondEntity
from .utils import BondDevice, BondHub from .utils import BondDevice, BondHub
@ -33,10 +33,14 @@ async def async_setup_entry(
async_add_entities: Callable[[List[Entity], bool], None], async_add_entities: Callable[[List[Entity], bool], None],
) -> None: ) -> None:
"""Set up Bond fan devices.""" """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 = [ 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) async_add_entities(fans, True)
@ -45,9 +49,9 @@ async def async_setup_entry(
class BondFan(BondEntity, FanEntity): class BondFan(BondEntity, FanEntity):
"""Representation of a Bond fan.""" """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.""" """Create HA entity representing Bond fan."""
super().__init__(hub, device) super().__init__(hub, device, bpup_subs)
self._power: Optional[bool] = None self._power: Optional[bool] = None
self._speed: Optional[int] = None self._speed: Optional[int] = None

View File

@ -2,7 +2,7 @@
import logging import logging
from typing import Any, Callable, List, Optional 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 ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from . import BondHub from . import BondHub
from .const import DOMAIN from .const import BPUP_SUBS, DOMAIN, HUB
from .entity import BondEntity from .entity import BondEntity
from .utils import BondDevice from .utils import BondDevice
@ -27,28 +27,30 @@ async def async_setup_entry(
async_add_entities: Callable[[List[Entity], bool], None], async_add_entities: Callable[[List[Entity], bool], None],
) -> None: ) -> None:
"""Set up Bond light devices.""" """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] = [ fan_lights: List[Entity] = [
BondLight(hub, device) BondLight(hub, device, bpup_subs)
for device in hub.devices for device in hub.devices
if DeviceType.is_fan(device.type) and device.supports_light() if DeviceType.is_fan(device.type) and device.supports_light()
] ]
fireplaces: List[Entity] = [ fireplaces: List[Entity] = [
BondFireplace(hub, device) BondFireplace(hub, device, bpup_subs)
for device in hub.devices for device in hub.devices
if DeviceType.is_fireplace(device.type) if DeviceType.is_fireplace(device.type)
] ]
fp_lights: List[Entity] = [ fp_lights: List[Entity] = [
BondLight(hub, device, "light") BondLight(hub, device, bpup_subs, "light")
for device in hub.devices for device in hub.devices
if DeviceType.is_fireplace(device.type) and device.supports_light() if DeviceType.is_fireplace(device.type) and device.supports_light()
] ]
lights: List[Entity] = [ lights: List[Entity] = [
BondLight(hub, device) BondLight(hub, device, bpup_subs)
for device in hub.devices for device in hub.devices
if DeviceType.is_light(device.type) if DeviceType.is_light(device.type)
] ]
@ -60,10 +62,14 @@ class BondLight(BondEntity, LightEntity):
"""Representation of a Bond light.""" """Representation of a Bond light."""
def __init__( 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.""" """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._brightness: Optional[int] = None
self._light: Optional[int] = None self._light: Optional[int] = None
@ -110,9 +116,9 @@ class BondLight(BondEntity, LightEntity):
class BondFireplace(BondEntity, LightEntity): class BondFireplace(BondEntity, LightEntity):
"""Representation of a Bond-controlled fireplace.""" """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.""" """Create HA entity representing Bond fireplace."""
super().__init__(hub, device) super().__init__(hub, device, bpup_subs)
self._power: Optional[bool] = None self._power: Optional[bool] = None
# Bond flame level, 0-100 # Bond flame level, 0-100

View File

@ -3,7 +3,7 @@
"name": "Bond", "name": "Bond",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bond", "documentation": "https://www.home-assistant.io/integrations/bond",
"requirements": ["bond-api==0.1.8"], "requirements": ["bond-api==0.1.9"],
"zeroconf": ["_bond._tcp.local."], "zeroconf": ["_bond._tcp.local."],
"codeowners": ["@prystupa"], "codeowners": ["@prystupa"],
"quality_scale": "platinum" "quality_scale": "platinum"

View File

@ -1,14 +1,14 @@
"""Support for Bond generic devices.""" """Support for Bond generic devices."""
from typing import Any, Callable, List, Optional 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.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import DOMAIN from .const import BPUP_SUBS, DOMAIN, HUB
from .entity import BondEntity from .entity import BondEntity
from .utils import BondDevice, BondHub from .utils import BondDevice, BondHub
@ -19,10 +19,12 @@ async def async_setup_entry(
async_add_entities: Callable[[List[Entity], bool], None], async_add_entities: Callable[[List[Entity], bool], None],
) -> None: ) -> None:
"""Set up Bond generic devices.""" """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 = [ switches = [
BondSwitch(hub, device) BondSwitch(hub, device, bpup_subs)
for device in hub.devices for device in hub.devices
if DeviceType.is_generic(device.type) if DeviceType.is_generic(device.type)
] ]
@ -33,9 +35,9 @@ async def async_setup_entry(
class BondSwitch(BondEntity, SwitchEntity): class BondSwitch(BondEntity, SwitchEntity):
"""Representation of a Bond generic device.""" """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).""" """Create HA entity representing Bond generic device (switch)."""
super().__init__(hub, device) super().__init__(hub, device, bpup_subs)
self._power: Optional[bool] = None self._power: Optional[bool] = None

View File

@ -367,7 +367,7 @@ blockchain==1.4.4
# bme680==1.0.5 # bme680==1.0.5
# homeassistant.components.bond # homeassistant.components.bond
bond-api==0.1.8 bond-api==0.1.9
# homeassistant.components.amazon_polly # homeassistant.components.amazon_polly
# homeassistant.components.route53 # homeassistant.components.route53

View File

@ -201,7 +201,7 @@ blebox_uniapi==1.3.2
blinkpy==0.16.4 blinkpy==0.16.4
# homeassistant.components.bond # homeassistant.components.bond
bond-api==0.1.8 bond-api==0.1.9
# homeassistant.components.braviatv # homeassistant.components.braviatv
bravia-tv==1.0.8 bravia-tv==1.0.8

View File

@ -3,7 +3,7 @@ from asyncio import TimeoutError as AsyncIOTimeoutError
from contextlib import nullcontext from contextlib import nullcontext
from datetime import timedelta from datetime import timedelta
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from unittest.mock import patch from unittest.mock import MagicMock, patch
from homeassistant import core from homeassistant import core
from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN
@ -33,9 +33,11 @@ async def setup_bond_entity(
"""Set up Bond entity.""" """Set up Bond entity."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
with patch_bond_version(enabled=patch_version), patch_bond_device_ids( with patch_start_bpup(), patch_bond_version(
enabled=patch_device_ids enabled=patch_version
), patch_setup_entry("cover", enabled=patch_platforms), patch_setup_entry( ), patch_bond_device_ids(enabled=patch_device_ids), patch_setup_entry(
"cover", enabled=patch_platforms
), patch_setup_entry(
"fan", enabled=patch_platforms "fan", enabled=patch_platforms
), patch_setup_entry( ), patch_setup_entry(
"light", enabled=patch_platforms "light", enabled=patch_platforms
@ -65,7 +67,7 @@ async def setup_platform(
with patch("homeassistant.components.bond.PLATFORMS", [platform]): with patch("homeassistant.components.bond.PLATFORMS", [platform]):
with patch_bond_version(return_value=bond_version), patch_bond_device_ids( with patch_bond_version(return_value=bond_version), patch_bond_device_ids(
return_value=[bond_device_id] return_value=[bond_device_id]
), patch_bond_device( ), patch_start_bpup(), patch_bond_device(
return_value=discovered_device return_value=discovered_device
), patch_bond_device_properties( ), patch_bond_device_properties(
return_value=props 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(): def patch_bond_action():
"""Patch Bond API action endpoint.""" """Patch Bond API action endpoint."""
return patch("homeassistant.components.bond.Bond.action") return patch("homeassistant.components.bond.Bond.action")

View File

@ -20,6 +20,7 @@ from .common import (
patch_bond_device_state, patch_bond_device_state,
patch_bond_version, patch_bond_version,
patch_setup_entry, patch_setup_entry,
patch_start_bpup,
setup_bond_entity, setup_bond_entity,
) )
@ -141,7 +142,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant):
"target": "test-model", "target": "test-model",
"fw_ver": "test-version", "fw_ver": "test-version",
} }
), patch_bond_device_ids( ), patch_start_bpup(), patch_bond_device_ids(
return_value=["bond-device-id", "device_id"] return_value=["bond-device-id", "device_id"]
), patch_bond_device( ), patch_bond_device(
return_value={ return_value={