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.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(

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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]:
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 = bond.getDeviceIds()
device_ids = self.bond.getDeviceIds()
devices = [
BondDevice(device_id, bond.getDevice(device_id)) for device_id in device_ids
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.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(

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.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()