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
This commit is contained in:
Eugene Prystupa 2020-07-12 12:31:53 -04:00 committed by GitHub
parent 6826a8829c
commit c6ab2c5d0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 136 additions and 50 deletions

View file

@ -6,8 +6,10 @@ from bond import Bond
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
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN from .const import DOMAIN
from .utils import BondHub
PLATFORMS = ["cover", "fan"] PLATFORMS = ["cover", "fan"]
@ -23,7 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
token = entry.data[CONF_ACCESS_TOKEN] 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: for component in PLATFORMS:
hass.async_create_task( hass.async_create_task(

View file

@ -1,7 +1,7 @@
"""Support for Bond covers.""" """Support for Bond covers."""
from typing import Any, Callable, List, Optional 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.components.cover import DEVICE_CLASS_SHADE, CoverEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -10,7 +10,7 @@ from homeassistant.helpers.entity import Entity
from .const import DOMAIN from .const import DOMAIN
from .entity import BondEntity from .entity import BondEntity
from .utils import BondDevice, get_bond_devices from .utils import BondDevice, BondHub
async def async_setup_entry( async def async_setup_entry(
@ -19,12 +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."""
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 = [ covers = [
BondCover(bond, device) BondCover(hub, device)
for device in devices for device in devices
if device.type == DeviceTypes.MOTORIZED_SHADES if device.type == DeviceTypes.MOTORIZED_SHADES
] ]
@ -35,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, bond: Bond, device: BondDevice): def __init__(self, hub: BondHub, device: BondDevice):
"""Create HA entity representing Bond cover.""" """Create HA entity representing Bond cover."""
super().__init__(bond, device) super().__init__(hub, device)
self._closed: Optional[bool] = None self._closed: Optional[bool] = None
@ -48,7 +48,7 @@ class BondCover(BondEntity, CoverEntity):
def update(self): def update(self):
"""Fetch assumed state of the cover from the hub using API.""" """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") cover_open = state.get("open")
self._closed = True if cover_open == 0 else False if cover_open == 1 else None 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: def open_cover(self, **kwargs: Any) -> None:
"""Open the cover.""" """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: def close_cover(self, **kwargs: Any) -> None:
"""Close cover.""" """Close cover."""
self._bond.close(self._device.device_id) self._hub.bond.close(self._device.device_id)
def stop_cover(self, **kwargs): def stop_cover(self, **kwargs):
"""Hold cover.""" """Hold cover."""
self._bond.hold(self._device.device_id) self._hub.bond.hold(self._device.device_id)

View file

@ -1,20 +1,18 @@
"""An abstract class common to all Bond entities.""" """An abstract class common to all Bond entities."""
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from bond import Bond
from homeassistant.components.bond.utils import BondDevice
from homeassistant.const import ATTR_NAME from homeassistant.const import ATTR_NAME
from .const import DOMAIN from .const import DOMAIN
from .utils import BondDevice, BondHub
class BondEntity: class BondEntity:
"""Generic Bond entity encapsulating common features of any Bond controlled device.""" """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.""" """Initialize entity with API and device info."""
self._bond = bond self._hub = hub
self._device = device self._device = device
@property @property
@ -30,7 +28,11 @@ class BondEntity:
@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."""
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 @property
def assumed_state(self) -> bool: def assumed_state(self) -> bool:

View file

@ -1,7 +1,7 @@
"""Support for Bond fans.""" """Support for Bond fans."""
from typing import Any, Callable, List, Optional from typing import Any, Callable, List, Optional
from bond import Bond, DeviceTypes from bond import DeviceTypes
from homeassistant.components.fan import ( from homeassistant.components.fan import (
SPEED_HIGH, SPEED_HIGH,
@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity
from .const import DOMAIN from .const import DOMAIN
from .entity import BondEntity from .entity import BondEntity
from .utils import BondDevice, get_bond_devices from .utils import BondDevice, BondHub
async def async_setup_entry( async def async_setup_entry(
@ -26,12 +26,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 fan devices.""" """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 = [ fans = [
BondFan(bond, device) BondFan(hub, device)
for device in devices for device in devices
if device.type == DeviceTypes.CEILING_FAN if device.type == DeviceTypes.CEILING_FAN
] ]
@ -42,9 +42,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, bond: Bond, device: BondDevice): def __init__(self, hub: BondHub, device: BondDevice):
"""Create HA entity representing Bond fan.""" """Create HA entity representing Bond fan."""
super().__init__(bond, device) super().__init__(hub, device)
self._power: Optional[bool] = None self._power: Optional[bool] = None
self._speed: Optional[int] = None self._speed: Optional[int] = None
@ -74,21 +74,21 @@ class BondFan(BondEntity, FanEntity):
def update(self): def update(self):
"""Fetch assumed state of the fan from the hub using API.""" """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._power = state.get("power")
self._speed = state.get("speed") self._speed = state.get("speed")
def set_speed(self, speed: str) -> None: def set_speed(self, speed: str) -> None:
"""Set the desired speed for the fan.""" """Set the desired speed for the fan."""
speed_index = self.speed_list.index(speed) 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: def turn_on(self, speed: Optional[str] = None, **kwargs) -> None:
"""Turn on the fan.""" """Turn on the fan."""
if speed is not None: if speed is not None:
self.set_speed(speed) 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: def turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off.""" """Turn the fan off."""
self._bond.turnOff(self._device.device_id) self._hub.bond.turnOff(self._device.device_id)

View file

@ -1,11 +1,9 @@
"""Reusable utilities for the Bond component.""" """Reusable utilities for the Bond component."""
from typing import List from typing import List, Optional
from bond import Bond from bond import Bond
from homeassistant.core import HomeAssistant
class BondDevice: class BondDevice:
"""Helper device class to hold ID and attributes together.""" """Helper device class to hold ID and attributes together."""
@ -31,10 +29,38 @@ class BondDevice:
return command in actions return command in actions
def get_bond_devices(hass: HomeAssistant, bond: Bond) -> List[BondDevice]: class BondHub:
"""Fetch all available devices using Bond API.""" """Hub device representing Bond Bridge."""
device_ids = bond.getDeviceIds()
devices = [ def __init__(self, bond: Bond):
BondDevice(device_id, bond.getDevice(device_id)) for device_id in device_ids """Initialize Bond Hub."""
] self.bond: Bond = bond
return devices 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")

View file

@ -9,6 +9,23 @@ from homeassistant.setup import async_setup_component
from tests.async_mock import patch from tests.async_mock import patch
from tests.common import MockConfigEntry 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( async def setup_platform(
hass: core.HomeAssistant, platform: str, discovered_device: Dict[str, Any] hass: core.HomeAssistant, platform: str, discovered_device: Dict[str, Any]
@ -21,6 +38,8 @@ async def setup_platform(
mock_entry.add_to_hass(hass) mock_entry.add_to_hass(hass)
with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch( with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch(
"homeassistant.components.bond.Bond.getVersion", return_value=MOCK_HUB_VERSION
), patch(
"homeassistant.components.bond.Bond.getDeviceIds", "homeassistant.components.bond.Bond.getDeviceIds",
return_value=["bond-device-id"], return_value=["bond-device-id"],
), patch( ), patch(

View file

@ -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.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .common import setup_bond_entity
from tests.async_mock import patch from tests.async_mock import patch
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -16,22 +19,45 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant):
assert result is True 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.""" """Test that configuring entry sets up cover domain."""
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"},
) )
config_entry.add_to_hass(hass)
with patch( with patch(
"homeassistant.components.bond.cover.async_setup_entry" "homeassistant.components.bond.cover.async_setup_entry"
) as mock_cover_async_setup_entry: ) as mock_cover_async_setup_entry, patch(
result = await hass.config_entries.async_setup(config_entry.entry_id) "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 assert result is True
await hass.async_block_till_done() 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_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): async def test_unload_config_entry(hass: HomeAssistant):
@ -39,15 +65,13 @@ async def test_unload_config_entry(hass: HomeAssistant):
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"},
) )
config_entry.add_to_hass(hass)
with patch( with patch("homeassistant.components.bond.cover.async_setup_entry"), patch(
"homeassistant.components.bond.cover.async_setup_entry", return_value=True "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 assert result is True
await hass.async_block_till_done() 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.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()