diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index d965b6485f8..cbad37b1b87 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -4,6 +4,7 @@ import logging from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN @@ -31,6 +32,7 @@ SUPPORTED_PLATFORMS = [ BINARY_SENSOR_DOMAIN, CLIMATE_DOMAIN, COVER_DOMAIN, + FAN_DOMAIN, LIGHT_DOMAIN, LOCK_DOMAIN, SCENE_DOMAIN, @@ -53,6 +55,9 @@ DAMPERS = ["Level controllable output"] WINDOW_COVERS = ["Window covering device", "Window covering controller"] COVER_TYPES = DAMPERS + WINDOW_COVERS +# Fans +FANS = ["Fan"] + # Locks LOCKS = ["Door Lock"] LOCK_TYPES = LOCKS diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py new file mode 100644 index 00000000000..f33b2ca86ba --- /dev/null +++ b/homeassistant/components/deconz/fan.py @@ -0,0 +1,119 @@ +"""Support for deCONZ switches.""" +from homeassistant.components.fan import ( + DOMAIN, + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import FANS, NEW_LIGHT +from .deconz_device import DeconzDevice +from .gateway import get_gateway_from_config_entry + +SPEEDS = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 2, SPEED_HIGH: 4} +SUPPORTED_ON_SPEEDS = {1: SPEED_LOW, 2: SPEED_MEDIUM, 4: SPEED_HIGH} + + +def convert_speed(speed: int) -> str: + """Convert speed from deCONZ to HASS. + + Fallback to medium speed if unsupported by HASS fan platform. + """ + if speed in SPEEDS.values(): + for hass_speed, deconz_speed in SPEEDS.items(): + if speed == deconz_speed: + return hass_speed + return SPEED_MEDIUM + + +async def async_setup_entry(hass, config_entry, async_add_entities) -> None: + """Set up fans for deCONZ component. + + Fans are based on the same device class as lights in deCONZ. + """ + gateway = get_gateway_from_config_entry(hass, config_entry) + gateway.entities[DOMAIN] = set() + + @callback + def async_add_fan(lights) -> None: + """Add fan from deCONZ.""" + entities = [] + + for light in lights: + + if light.type in FANS and light.uniqueid not in gateway.entities[DOMAIN]: + entities.append(DeconzFan(light, gateway)) + + if entities: + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_fan + ) + ) + + async_add_fan(gateway.api.lights.values()) + + +class DeconzFan(DeconzDevice, FanEntity): + """Representation of a deCONZ fan.""" + + TYPE = DOMAIN + + def __init__(self, device, gateway) -> None: + """Set up fan.""" + super().__init__(device, gateway) + + self._default_on_speed = SPEEDS[SPEED_MEDIUM] + if self.speed != SPEED_OFF: + self._default_on_speed = self._device.speed + + self._features = SUPPORT_SET_SPEED + + @property + def is_on(self) -> bool: + """Return true if fan is on.""" + return self.speed != SPEED_OFF + + @property + def speed(self) -> int: + """Return the current speed.""" + return convert_speed(self._device.speed) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return list(SPEEDS) + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._features + + @callback + def async_update_callback(self, force_update=False) -> None: + """Store latest configured speed from the device.""" + if self.speed != SPEED_OFF and self._device.speed != self._default_on_speed: + self._default_on_speed = self._device.speed + super().async_update_callback(force_update) + + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + data = {"speed": SPEEDS[speed]} + await self._device.async_set_state(data) + + async def async_turn_on(self, speed: str = None, **kwargs) -> None: + """Turn on fan.""" + if not speed: + speed = convert_speed(self._default_on_speed) + await self.async_set_speed(speed) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off fan.""" + await self.async_set_speed(SPEED_OFF) diff --git a/tests/components/deconz/test_fan.py b/tests/components/deconz/test_fan.py new file mode 100644 index 00000000000..460682aac09 --- /dev/null +++ b/tests/components/deconz/test_fan.py @@ -0,0 +1,185 @@ +"""deCONZ fan platform tests.""" +from copy import deepcopy + +from homeassistant.components import deconz +from homeassistant.components.deconz.gateway import get_gateway_from_config_entry +import homeassistant.components.fan as fan +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component + +from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration + +from tests.async_mock import patch + +FANS = { + "1": { + "etag": "432f3de28965052961a99e3c5494daf4", + "hascolor": False, + "manufacturername": "King Of Fans, Inc.", + "modelid": "HDC52EastwindFan", + "name": "Ceiling fan", + "state": { + "alert": "none", + "bri": 254, + "on": False, + "reachable": True, + "speed": 4, + }, + "swversion": "0000000F", + "type": "Fan", + "uniqueid": "00:22:a3:00:00:27:8b:81-01", + } +} + + +async def test_platform_manually_configured(hass): + """Test that we do not discover anything or try to set up a gateway.""" + assert ( + await async_setup_component( + hass, fan.DOMAIN, {"fan": {"platform": deconz.DOMAIN}} + ) + is True + ) + assert deconz.DOMAIN not in hass.data + + +async def test_no_fans(hass): + """Test that no fan entities are created.""" + await setup_deconz_integration(hass) + assert len(hass.states.async_all()) == 0 + + +async def test_fans(hass): + """Test that all supported fan entities are created.""" + data = deepcopy(DECONZ_WEB_REQUEST) + data["lights"] = deepcopy(FANS) + config_entry = await setup_deconz_integration(hass, get_state_response=data) + gateway = get_gateway_from_config_entry(hass, config_entry) + + assert len(hass.states.async_all()) == 2 # Light and fan + assert hass.states.get("fan.ceiling_fan") + + # Test states + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes["speed"] == fan.SPEED_HIGH + + state_changed_event = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 0}, + } + gateway.api.event_handler(state_changed_event) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_OFF + assert hass.states.get("fan.ceiling_fan").attributes["speed"] == fan.SPEED_OFF + + # Test service calls + + ceiling_fan_device = gateway.api.lights["1"] + + # Service turn on fan + + with patch.object( + ceiling_fan_device, "_request", return_value=True + ) as set_callback: + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {"entity_id": "fan.ceiling_fan"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 4}) + + # Service turn off fan + + with patch.object( + ceiling_fan_device, "_request", return_value=True + ) as set_callback: + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_OFF, + {"entity_id": "fan.ceiling_fan"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 0}) + + # Service set fan speed to low + + with patch.object( + ceiling_fan_device, "_request", return_value=True + ) as set_callback: + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + {"entity_id": "fan.ceiling_fan", fan.ATTR_SPEED: fan.SPEED_LOW}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 1}) + + # Service set fan speed to medium + + with patch.object( + ceiling_fan_device, "_request", return_value=True + ) as set_callback: + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + {"entity_id": "fan.ceiling_fan", fan.ATTR_SPEED: fan.SPEED_MEDIUM}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 2}) + + # Service set fan speed to high + + with patch.object( + ceiling_fan_device, "_request", return_value=True + ) as set_callback: + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + {"entity_id": "fan.ceiling_fan", fan.ATTR_SPEED: fan.SPEED_HIGH}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 4}) + + # Service set fan speed to off + + with patch.object( + ceiling_fan_device, "_request", return_value=True + ) as set_callback: + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + {"entity_id": "fan.ceiling_fan", fan.ATTR_SPEED: fan.SPEED_OFF}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("put", "/lights/1/state", json={"speed": 0}) + + # Verify that an unsupported speed gets converted to default speed "medium" + + state_changed_event = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 3}, + } + gateway.api.event_handler(state_changed_event) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes["speed"] == fan.SPEED_MEDIUM + + await hass.config_entries.async_unload(config_entry.entry_id) + + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 11516b36eca..c54267c1d03 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -90,11 +90,12 @@ async def test_gateway_setup(hass): assert forward_entry_setup.mock_calls[0][1] == (config_entry, "binary_sensor") assert forward_entry_setup.mock_calls[1][1] == (config_entry, "climate") assert forward_entry_setup.mock_calls[2][1] == (config_entry, "cover") - assert forward_entry_setup.mock_calls[3][1] == (config_entry, "light") - assert forward_entry_setup.mock_calls[4][1] == (config_entry, "lock") - assert forward_entry_setup.mock_calls[5][1] == (config_entry, "scene") - assert forward_entry_setup.mock_calls[6][1] == (config_entry, "sensor") - assert forward_entry_setup.mock_calls[7][1] == (config_entry, "switch") + assert forward_entry_setup.mock_calls[3][1] == (config_entry, "fan") + assert forward_entry_setup.mock_calls[4][1] == (config_entry, "light") + assert forward_entry_setup.mock_calls[5][1] == (config_entry, "lock") + assert forward_entry_setup.mock_calls[6][1] == (config_entry, "scene") + assert forward_entry_setup.mock_calls[7][1] == (config_entry, "sensor") + assert forward_entry_setup.mock_calls[8][1] == (config_entry, "switch") async def test_gateway_retry(hass):