diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 697023546ac..5781f1d4bc1 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS = ["cover"] +PLATFORMS = ["cover", "fan"] async def async_setup(hass: HomeAssistant, config: dict): diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py new file mode 100644 index 00000000000..170a72bbfe9 --- /dev/null +++ b/homeassistant/components/bond/fan.py @@ -0,0 +1,115 @@ +"""Support for Bond fans.""" +from typing import Any, Callable, Dict, List, Optional + +from bond import BOND_DEVICE_TYPE_CEILING_FAN, Bond + +from homeassistant.components.fan import ( + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity + +from ...const import ATTR_NAME +from .const import DOMAIN +from .utils import BondDevice, get_bond_devices + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up Bond fan devices.""" + bond: Bond = hass.data[DOMAIN][entry.entry_id] + + devices = await hass.async_add_executor_job(get_bond_devices, hass, bond) + + fans = [ + BondFan(bond, device) + for device in devices + if device.type == BOND_DEVICE_TYPE_CEILING_FAN + ] + + async_add_entities(fans, True) + + +class BondFan(FanEntity): + """Representation of a Bond fan.""" + + def __init__(self, bond: Bond, device: BondDevice): + """Create HA entity representing Bond fan.""" + self._bond = bond + self._device = device + + self._power: Optional[bool] = None + self._speed: Optional[int] = None + + @property + def unique_id(self) -> Optional[str]: + """Get unique ID for the entity.""" + return self._device.device_id + + @property + def name(self) -> Optional[str]: + """Get entity name.""" + return self._device.name + + @property + def device_info(self) -> Optional[Dict[str, Any]]: + """Get a an HA device representing this fan.""" + return {ATTR_NAME: self.name, "identifiers": {(DOMAIN, self._device.device_id)}} + + @property + def assumed_state(self) -> bool: + """Let HA know this entity relies on an assumed state tracked by Bond.""" + return True + + @property + def supported_features(self) -> int: + """Flag supported features.""" + features = 0 + if self._device.supports_command("SetSpeed"): + features |= SUPPORT_SET_SPEED + return features + + @property + def speed(self) -> Optional[str]: + """Return the current speed.""" + if self._power is None: + return None + if self._power == 0: + return SPEED_OFF + + return self.speed_list[self._speed] if self._speed is not None else None + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + def update(self): + """Fetch assumed state of the fan from the hub using API.""" + state: dict = self._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) + + 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) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + self._bond.turnOff(self._device.device_id) diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 9f05252965b..48822d35fff 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -16,15 +16,20 @@ class BondDevice: self._attrs = attrs @property - def name(self): + def name(self) -> str: """Get the name of this device.""" return self._attrs["name"] @property - def type(self): + def type(self) -> str: """Get the type of this device.""" return self._attrs["type"] + def supports_command(self, command: str) -> bool: + """Return True if this device supports specified command.""" + actions: List[str] = self._attrs["actions"] + return command in actions + def get_bond_devices(hass: HomeAssistant, bond: Bond) -> List[BondDevice]: """Fetch all available devices using Bond API.""" diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py new file mode 100644 index 00000000000..e83c1f86511 --- /dev/null +++ b/tests/components/bond/test_fan.py @@ -0,0 +1,130 @@ +"""Tests for the Bond fan device.""" +from datetime import timedelta + +from bond import BOND_DEVICE_TYPE_CEILING_FAN + +from homeassistant import core +from homeassistant.components import fan +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.util import utcnow + +from ...common import async_fire_time_changed +from .common import setup_platform + +from tests.async_mock import patch + +TEST_DEVICE_IDS = ["device-1"] +TEST_FAN_DEVICE = { + "name": "name-1", + "type": BOND_DEVICE_TYPE_CEILING_FAN, + "actions": ["SetSpeed"], +} + + +async def test_entity_registry(hass: core.HomeAssistant): + """Tests that the devices are registered in the entity registry.""" + + with patch( + "homeassistant.components.bond.Bond.getDeviceIds", return_value=TEST_DEVICE_IDS + ), patch( + "homeassistant.components.bond.Bond.getDevice", return_value=TEST_FAN_DEVICE + ), patch( + "homeassistant.components.bond.Bond.getDeviceState", return_value={} + ): + await setup_platform(hass, FAN_DOMAIN) + + registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() + assert [key for key in registry.entities.keys()] == ["fan.name_1"] + + +async def test_turn_on_fan(hass: core.HomeAssistant): + """Tests that turn on command delegates to API.""" + + with patch( + "homeassistant.components.bond.Bond.getDeviceIds", return_value=TEST_DEVICE_IDS + ), patch( + "homeassistant.components.bond.Bond.getDevice", return_value=TEST_FAN_DEVICE + ), patch( + "homeassistant.components.bond.Bond.getDeviceState", return_value={} + ): + await setup_platform(hass, FAN_DOMAIN) + + with patch("homeassistant.components.bond.Bond.turnOn") as mock_turn_on, patch( + "homeassistant.components.bond.Bond.setSpeed" + ) as mock_set_speed: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.name_1", fan.ATTR_SPEED: fan.SPEED_LOW}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_set_speed.assert_called_once() + mock_turn_on.assert_called_once() + + +async def test_turn_off_fan(hass: core.HomeAssistant): + """Tests that turn off command delegates to API.""" + + with patch( + "homeassistant.components.bond.Bond.getDeviceIds", return_value=TEST_DEVICE_IDS + ), patch( + "homeassistant.components.bond.Bond.getDevice", return_value=TEST_FAN_DEVICE + ), patch( + "homeassistant.components.bond.Bond.getDeviceState", return_value={} + ): + await setup_platform(hass, FAN_DOMAIN) + + with patch("homeassistant.components.bond.Bond.turnOff") as mock_turn_off: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "fan.name_1"}, blocking=True, + ) + await hass.async_block_till_done() + mock_turn_off.assert_called_once() + + +async def test_update_reports_fan_on(hass: core.HomeAssistant): + """Tests that update command sets correct state when Bond API reports fan power is on.""" + + with patch( + "homeassistant.components.bond.Bond.getDeviceIds", return_value=TEST_DEVICE_IDS + ), patch( + "homeassistant.components.bond.Bond.getDevice", return_value=TEST_FAN_DEVICE + ), patch( + "homeassistant.components.bond.Bond.getDeviceState", return_value={} + ): + await setup_platform(hass, FAN_DOMAIN) + + with patch( + "homeassistant.components.bond.Bond.getDeviceState", + return_value={"power": 1, "speed": 1}, + ): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("fan.name_1").state == "on" + + +async def test_update_reports_fan_off(hass: core.HomeAssistant): + """Tests that update command sets correct state when Bond API reports fan power is off.""" + + with patch( + "homeassistant.components.bond.Bond.getDeviceIds", return_value=TEST_DEVICE_IDS + ), patch( + "homeassistant.components.bond.Bond.getDevice", return_value=TEST_FAN_DEVICE + ), patch( + "homeassistant.components.bond.Bond.getDeviceState", return_value={} + ): + await setup_platform(hass, FAN_DOMAIN) + + with patch( + "homeassistant.components.bond.Bond.getDeviceState", + return_value={"power": 0, "speed": 1}, + ): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("fan.name_1").state == "off"