diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 1ca4c8ff9c2..3d2e61fdd7b 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -1,4 +1,7 @@ -"""Support for deCONZ switches.""" +"""Support for deCONZ fans.""" + +from typing import Optional + from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, @@ -10,25 +13,19 @@ from homeassistant.components.fan import ( ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) 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} +ORDERED_NAMED_FAN_SPEEDS = [1, 2, 3, 4] - -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 +LEGACY_SPEED_TO_DECONZ = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 2, SPEED_HIGH: 4} +LEGACY_DECONZ_TO_SPEED = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 4: SPEED_HIGH} async def async_setup_entry(hass, config_entry, async_add_entities) -> None: @@ -67,8 +64,8 @@ class DeconzFan(DeconzDevice, FanEntity): """Set up fan.""" super().__init__(device, gateway) - self._default_on_speed = SPEEDS[SPEED_MEDIUM] - if self.speed != SPEED_OFF: + self._default_on_speed = 2 + if self._device.speed in ORDERED_NAMED_FAN_SPEEDS: self._default_on_speed = self._device.speed self._features = SUPPORT_SET_SPEED @@ -76,17 +73,58 @@ class DeconzFan(DeconzDevice, FanEntity): @property def is_on(self) -> bool: """Return true if fan is on.""" - return self.speed != SPEED_OFF + return self._device.speed != 0 @property - def speed(self) -> int: - """Return the current speed.""" - return convert_speed(self._device.speed) + def percentage(self) -> Optional[int]: + """Return the current speed percentage.""" + if self._device.speed == 0: + return 0 + if self._device.speed not in ORDERED_NAMED_FAN_SPEEDS: + return + return ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, self._device.speed + ) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) @property def speed_list(self) -> list: - """Get the list of available speeds.""" - return list(SPEEDS) + """Get the list of available speeds. + + Legacy fan support. + """ + return list(LEGACY_SPEED_TO_DECONZ) + + def speed_to_percentage(self, speed: str) -> int: + """Convert speed to percentage. + + Legacy fan support. + """ + if speed == SPEED_OFF: + return 0 + + if speed not in LEGACY_SPEED_TO_DECONZ: + speed = SPEED_MEDIUM + + return ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, LEGACY_SPEED_TO_DECONZ[speed] + ) + + def percentage_to_speed(self, percentage: int) -> str: + """Convert percentage to speed. + + Legacy fan support. + """ + if percentage == 0: + return SPEED_OFF + return LEGACY_DECONZ_TO_SPEED.get( + percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage), + SPEED_MEDIUM, + ) @property def supported_features(self) -> int: @@ -96,24 +134,26 @@ class DeconzFan(DeconzDevice, FanEntity): @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: + if self._device.speed in ORDERED_NAMED_FAN_SPEEDS: self._default_on_speed = self._device.speed super().async_update_callback(force_update) + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + await self._device.set_speed( + percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) + ) + async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if speed not in SPEEDS: + """Set the speed of the fan. + + Legacy fan support. + """ + if speed not in LEGACY_SPEED_TO_DECONZ: raise ValueError(f"Unsupported speed {speed}") - await self._device.set_speed(SPEEDS[speed]) + await self._device.set_speed(LEGACY_SPEED_TO_DECONZ[speed]) - # - # The fan entity model has changed to use percentages and preset_modes - # instead of speeds. - # - # Please review - # https://developers.home-assistant.io/docs/core/entity/fan/ - # async def async_turn_on( self, speed: str = None, @@ -122,10 +162,15 @@ class DeconzFan(DeconzDevice, FanEntity): **kwargs, ) -> None: """Turn on fan.""" - if not speed: - speed = convert_speed(self._default_on_speed) - await self.async_set_speed(speed) + new_speed = self._default_on_speed + + if percentage is not None: + new_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + + await self._device.set_speed(new_speed) async def async_turn_off(self, **kwargs) -> None: """Turn off fan.""" - await self.async_set_speed(SPEED_OFF) + await self._device.set_speed(0) diff --git a/tests/components/deconz/test_fan.py b/tests/components/deconz/test_fan.py index 930645689f8..ddd4a4e46f4 100644 --- a/tests/components/deconz/test_fan.py +++ b/tests/components/deconz/test_fan.py @@ -5,8 +5,10 @@ from unittest.mock import patch import pytest from homeassistant.components.fan import ( + ATTR_PERCENTAGE, ATTR_SPEED, DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, SERVICE_SET_SPEED, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -59,10 +61,62 @@ async def test_fans(hass, aioclient_mock, mock_deconz_websocket): assert len(hass.states.async_all()) == 2 # Light and fan assert hass.states.get("fan.ceiling_fan").state == STATE_ON - assert hass.states.get("fan.ceiling_fan").attributes[ATTR_SPEED] == SPEED_HIGH + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 100 # Test states + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 1}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 25 + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 2}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 50 + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 3}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 75 + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 4}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 100 + event_changed_light = { "t": "event", "e": "changed", @@ -74,13 +128,13 @@ async def test_fans(hass, aioclient_mock, mock_deconz_websocket): await hass.async_block_till_done() assert hass.states.get("fan.ceiling_fan").state == STATE_OFF - assert hass.states.get("fan.ceiling_fan").attributes[ATTR_SPEED] == SPEED_OFF + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 0 # Test service calls mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") - # Service turn on fan + # Service turn on fan using saved default_on_speed await hass.services.async_call( FAN_DOMAIN, @@ -100,6 +154,265 @@ async def test_fans(hass, aioclient_mock, mock_deconz_websocket): ) assert aioclient_mock.mock_calls[2][2] == {"speed": 0} + # Service turn on fan to 20% + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_PERCENTAGE: 20}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"speed": 1} + + # Service set fan percentage to 20% + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_PERCENTAGE: 20}, + blocking=True, + ) + assert aioclient_mock.mock_calls[4][2] == {"speed": 1} + + # Service set fan percentage to 40% + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_PERCENTAGE: 40}, + blocking=True, + ) + assert aioclient_mock.mock_calls[5][2] == {"speed": 2} + + # Service set fan percentage to 60% + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_PERCENTAGE: 60}, + blocking=True, + ) + assert aioclient_mock.mock_calls[6][2] == {"speed": 3} + + # Service set fan percentage to 80% + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_PERCENTAGE: 80}, + blocking=True, + ) + assert aioclient_mock.mock_calls[7][2] == {"speed": 4} + + # Service set fan percentage to 0% does not equal off + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_PERCENTAGE: 0}, + blocking=True, + ) + assert aioclient_mock.mock_calls[8][2] == {"speed": 1} + + # Events with an unsupported speed does not get converted + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 5}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert not hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] + + await hass.config_entries.async_unload(config_entry.entry_id) + + states = hass.states.async_all() + assert len(states) == 2 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +async def test_fans_legacy_speed_modes(hass, aioclient_mock, mock_deconz_websocket): + """Test that all supported fan entities are created. + + Legacy fan support. + """ + data = { + "lights": { + "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", + } + } + } + + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + assert len(hass.states.async_all()) == 2 # Light and fan + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_SPEED] == SPEED_HIGH + + # Test states + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 1}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 25 + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_SPEED] == SPEED_LOW + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 2}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 50 + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_SPEED] == SPEED_MEDIUM + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 3}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 75 + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_SPEED] == SPEED_MEDIUM + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 4}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_ON + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 100 + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_SPEED] == SPEED_HIGH + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"speed": 0}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("fan.ceiling_fan").state == STATE_OFF + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_PERCENTAGE] == 0 + assert hass.states.get("fan.ceiling_fan").attributes[ATTR_SPEED] == SPEED_OFF + + # Test service calls + + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") + + # Service turn on fan using saved default_on_speed + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.ceiling_fan"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"speed": 4} + + # Service turn on fan with speed_off + # async_turn_on_compat use speed_to_percentage which will return 0 + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_OFF}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"speed": 1} + + # Service turn on fan with bad speed + # async_turn_on_compat use speed_to_percentage which will convert to SPEED_MEDIUM -> 2 + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: "bad"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"speed": 2} + + # Service turn on fan to low speed + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_LOW}, + blocking=True, + ) + assert aioclient_mock.mock_calls[4][2] == {"speed": 1} + + # Service turn on fan to medium speed + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_MEDIUM}, + blocking=True, + ) + assert aioclient_mock.mock_calls[5][2] == {"speed": 2} + + # Service turn on fan to high speed + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_HIGH}, + blocking=True, + ) + assert aioclient_mock.mock_calls[6][2] == {"speed": 4} + # Service set fan speed to low await hass.services.async_call( @@ -108,7 +421,7 @@ async def test_fans(hass, aioclient_mock, mock_deconz_websocket): {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_LOW}, blocking=True, ) - assert aioclient_mock.mock_calls[3][2] == {"speed": 1} + assert aioclient_mock.mock_calls[7][2] == {"speed": 1} # Service set fan speed to medium @@ -118,7 +431,7 @@ async def test_fans(hass, aioclient_mock, mock_deconz_websocket): {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_MEDIUM}, blocking=True, ) - assert aioclient_mock.mock_calls[4][2] == {"speed": 2} + assert aioclient_mock.mock_calls[8][2] == {"speed": 2} # Service set fan speed to high @@ -128,7 +441,7 @@ async def test_fans(hass, aioclient_mock, mock_deconz_websocket): {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_HIGH}, blocking=True, ) - assert aioclient_mock.mock_calls[5][2] == {"speed": 4} + assert aioclient_mock.mock_calls[9][2] == {"speed": 4} # Service set fan speed to off @@ -138,7 +451,7 @@ async def test_fans(hass, aioclient_mock, mock_deconz_websocket): {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_OFF}, blocking=True, ) - assert aioclient_mock.mock_calls[6][2] == {"speed": 0} + assert aioclient_mock.mock_calls[10][2] == {"speed": 0} # Service set fan speed to unsupported value