From c6ab2c5d0a32187a81e60435ef3dcb849252edda Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Sun, 12 Jul 2020 12:31:53 -0400 Subject: [PATCH] Add Bond hub as a device for bond entities (#37772) * Introduce Bond Hub concept * Read Hub version information when setting up entry * Link entities to Hub using via_device * Add test to verify created Hub device properties --- homeassistant/components/bond/__init__.py | 17 ++++++++- homeassistant/components/bond/cover.py | 22 +++++------ homeassistant/components/bond/entity.py | 14 ++++--- homeassistant/components/bond/fan.py | 22 +++++------ homeassistant/components/bond/utils.py | 46 ++++++++++++++++++----- tests/components/bond/common.py | 19 ++++++++++ tests/components/bond/test_init.py | 46 +++++++++++++++++------ 7 files changed, 136 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 5781f1d4bc1..aa069bea17e 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -6,8 +6,10 @@ from bond 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 .const import DOMAIN +from .utils import BondHub PLATFORMS = ["cover", "fan"] @@ -23,7 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): host = entry.data[CONF_HOST] token = entry.data[CONF_ACCESS_TOKEN] - hass.data[DOMAIN][entry.entry_id] = Bond(bondIp=host, bondToken=token) + bond = Bond(bondIp=host, bondToken=token) + hub = BondHub(bond) + await hass.async_add_executor_job(hub.setup) + hass.data[DOMAIN][entry.entry_id] = hub + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, hub.bond_id)}, + manufacturer="Olibra", + name=hub.bond_id, + model=hub.target, + sw_version=hub.fw_ver, + ) for component in PLATFORMS: hass.async_create_task( diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index de50eb45b3e..79ccfa9210e 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -1,7 +1,7 @@ """Support for Bond covers.""" from typing import Any, Callable, List, Optional -from bond import Bond, DeviceTypes +from bond import DeviceTypes from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -10,7 +10,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from .entity import BondEntity -from .utils import BondDevice, get_bond_devices +from .utils import BondDevice, BondHub async def async_setup_entry( @@ -19,12 +19,12 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond cover devices.""" - bond: Bond = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(get_bond_devices, hass, bond) + devices = await hass.async_add_executor_job(hub.get_bond_devices) covers = [ - BondCover(bond, device) + BondCover(hub, device) for device in devices if device.type == DeviceTypes.MOTORIZED_SHADES ] @@ -35,9 +35,9 @@ async def async_setup_entry( class BondCover(BondEntity, CoverEntity): """Representation of a Bond cover.""" - def __init__(self, bond: Bond, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice): """Create HA entity representing Bond cover.""" - super().__init__(bond, device) + super().__init__(hub, device) self._closed: Optional[bool] = None @@ -48,7 +48,7 @@ class BondCover(BondEntity, CoverEntity): def update(self): """Fetch assumed state of the cover from the hub using API.""" - state: dict = self._bond.getDeviceState(self._device.device_id) + state: dict = self._hub.bond.getDeviceState(self._device.device_id) cover_open = state.get("open") self._closed = True if cover_open == 0 else False if cover_open == 1 else None @@ -59,12 +59,12 @@ class BondCover(BondEntity, CoverEntity): def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._bond.open(self._device.device_id) + self._hub.bond.open(self._device.device_id) def close_cover(self, **kwargs: Any) -> None: """Close cover.""" - self._bond.close(self._device.device_id) + self._hub.bond.close(self._device.device_id) def stop_cover(self, **kwargs): """Hold cover.""" - self._bond.hold(self._device.device_id) + self._hub.bond.hold(self._device.device_id) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index aa4b86b2acf..0916297c074 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -1,20 +1,18 @@ """An abstract class common to all Bond entities.""" from typing import Any, Dict, Optional -from bond import Bond - -from homeassistant.components.bond.utils import BondDevice from homeassistant.const import ATTR_NAME from .const import DOMAIN +from .utils import BondDevice, BondHub class BondEntity: """Generic Bond entity encapsulating common features of any Bond controlled device.""" - def __init__(self, bond: Bond, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice): """Initialize entity with API and device info.""" - self._bond = bond + self._hub = hub self._device = device @property @@ -30,7 +28,11 @@ class BondEntity: @property def device_info(self) -> Optional[Dict[str, Any]]: """Get a an HA device representing this Bond controlled device.""" - return {ATTR_NAME: self.name, "identifiers": {(DOMAIN, self._device.device_id)}} + return { + ATTR_NAME: self.name, + "identifiers": {(DOMAIN, self._device.device_id)}, + "via_device": (DOMAIN, self._hub.bond_id), + } @property def assumed_state(self) -> bool: diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 8b0463fbd9f..a08b46679d0 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -1,7 +1,7 @@ """Support for Bond fans.""" from typing import Any, Callable, List, Optional -from bond import Bond, DeviceTypes +from bond import DeviceTypes from homeassistant.components.fan import ( SPEED_HIGH, @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from .entity import BondEntity -from .utils import BondDevice, get_bond_devices +from .utils import BondDevice, BondHub async def async_setup_entry( @@ -26,12 +26,12 @@ async def async_setup_entry( async_add_entities: Callable[[List[Entity], bool], None], ) -> None: """Set up Bond fan devices.""" - bond: Bond = hass.data[DOMAIN][entry.entry_id] + hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(get_bond_devices, hass, bond) + devices = await hass.async_add_executor_job(hub.get_bond_devices) fans = [ - BondFan(bond, device) + BondFan(hub, device) for device in devices if device.type == DeviceTypes.CEILING_FAN ] @@ -42,9 +42,9 @@ async def async_setup_entry( class BondFan(BondEntity, FanEntity): """Representation of a Bond fan.""" - def __init__(self, bond: Bond, device: BondDevice): + def __init__(self, hub: BondHub, device: BondDevice): """Create HA entity representing Bond fan.""" - super().__init__(bond, device) + super().__init__(hub, device) self._power: Optional[bool] = None self._speed: Optional[int] = None @@ -74,21 +74,21 @@ class BondFan(BondEntity, FanEntity): def update(self): """Fetch assumed state of the fan from the hub using API.""" - state: dict = self._bond.getDeviceState(self._device.device_id) + state: dict = self._hub.bond.getDeviceState(self._device.device_id) self._power = state.get("power") self._speed = state.get("speed") def set_speed(self, speed: str) -> None: """Set the desired speed for the fan.""" speed_index = self.speed_list.index(speed) - self._bond.setSpeed(self._device.device_id, speed=speed_index) + self._hub.bond.setSpeed(self._device.device_id, speed=speed_index) def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: """Turn on the fan.""" if speed is not None: self.set_speed(speed) - self._bond.turnOn(self._device.device_id) + self._hub.bond.turnOn(self._device.device_id) def turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - self._bond.turnOff(self._device.device_id) + self._hub.bond.turnOff(self._device.device_id) diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 48822d35fff..58284be5dc7 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -1,11 +1,9 @@ """Reusable utilities for the Bond component.""" -from typing import List +from typing import List, Optional from bond import Bond -from homeassistant.core import HomeAssistant - class BondDevice: """Helper device class to hold ID and attributes together.""" @@ -31,10 +29,38 @@ class BondDevice: return command in actions -def get_bond_devices(hass: HomeAssistant, bond: Bond) -> List[BondDevice]: - """Fetch all available devices using Bond API.""" - device_ids = bond.getDeviceIds() - devices = [ - BondDevice(device_id, bond.getDevice(device_id)) for device_id in device_ids - ] - return devices +class BondHub: + """Hub device representing Bond Bridge.""" + + def __init__(self, bond: Bond): + """Initialize Bond Hub.""" + self.bond: Bond = bond + self._version: Optional[dict] = None + + def setup(self): + """Read hub version information.""" + self._version = self.bond.getVersion() + + def get_bond_devices(self) -> List[BondDevice]: + """Fetch all available devices using Bond API.""" + device_ids = self.bond.getDeviceIds() + devices = [ + BondDevice(device_id, self.bond.getDevice(device_id)) + for device_id in device_ids + ] + return devices + + @property + def bond_id(self) -> str: + """Return unique Bond ID for this hub.""" + return self._version["bondid"] + + @property + def target(self) -> str: + """Return this hub model.""" + return self._version.get("target") + + @property + def fw_ver(self) -> str: + """Return this hub firmware version.""" + return self._version.get("fw_ver") diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 975efaf12c5..04cf3521a31 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -9,6 +9,23 @@ from homeassistant.setup import async_setup_component from tests.async_mock import patch from tests.common import MockConfigEntry +MOCK_HUB_VERSION: dict = {"bondid": "test-bond-id"} + + +async def setup_bond_entity( + hass: core.HomeAssistant, config_entry: MockConfigEntry, hub_version=None +): + """Set up Bond entity.""" + if hub_version is None: + hub_version = MOCK_HUB_VERSION + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.bond.Bond.getVersion", return_value=hub_version + ): + return await hass.config_entries.async_setup(config_entry.entry_id) + async def setup_platform( hass: core.HomeAssistant, platform: str, discovered_device: Dict[str, Any] @@ -21,6 +38,8 @@ async def setup_platform( mock_entry.add_to_hass(hass) with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch( + "homeassistant.components.bond.Bond.getVersion", return_value=MOCK_HUB_VERSION + ), patch( "homeassistant.components.bond.Bond.getDeviceIds", return_value=["bond-device-id"], ), patch( diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 0036b840b7f..aac250cb85b 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -3,8 +3,11 @@ from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from .common import setup_bond_entity + from tests.async_mock import patch from tests.common import MockConfigEntry @@ -16,22 +19,45 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant): assert result is True -async def test_async_setup_entry_sets_up_supported_domains(hass: HomeAssistant): +async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAssistant): """Test that configuring entry sets up cover domain.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, ) - config_entry.add_to_hass(hass) with patch( "homeassistant.components.bond.cover.async_setup_entry" - ) as mock_cover_async_setup_entry: - result = await hass.config_entries.async_setup(config_entry.entry_id) + ) as mock_cover_async_setup_entry, patch( + "homeassistant.components.bond.fan.async_setup_entry" + ) as mock_fan_async_setup_entry: + result = await setup_bond_entity( + hass, + config_entry, + hub_version={ + "bondid": "test-bond-id", + "target": "test-model", + "fw_ver": "test-version", + }, + ) assert result is True - await hass.async_block_till_done() + assert config_entry.entry_id in hass.data[DOMAIN] + assert config_entry.state == ENTRY_STATE_LOADED + + # verify hub device is registered correctly + device_registry = await dr.async_get_registry(hass) + hub = device_registry.async_get_device( + identifiers={(DOMAIN, "test-bond-id")}, connections=set() + ) + assert hub.name == "test-bond-id" + assert hub.manufacturer == "Olibra" + assert hub.model == "test-model" + assert hub.sw_version == "test-version" + + # verify supported domains are setup assert len(mock_cover_async_setup_entry.mock_calls) == 1 + assert len(mock_fan_async_setup_entry.mock_calls) == 1 async def test_unload_config_entry(hass: HomeAssistant): @@ -39,15 +65,13 @@ async def test_unload_config_entry(hass: HomeAssistant): config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.bond.cover.async_setup_entry", return_value=True + + with patch("homeassistant.components.bond.cover.async_setup_entry"), patch( + "homeassistant.components.bond.fan.async_setup_entry" ): - result = await hass.config_entries.async_setup(config_entry.entry_id) + result = await setup_bond_entity(hass, config_entry) assert result is True await hass.async_block_till_done() - assert config_entry.entry_id in hass.data[DOMAIN] - assert config_entry.state == ENTRY_STATE_LOADED await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done()