Add proper percentage support to deCONZ fan integration (#48187)

* Add proper percentage support to deCONZ fan integration

* Properly convert speed to percentage

* Remove disabled method

* Replace convert_speed with a dict
This commit is contained in:
Robert Svensson 2021-03-23 22:29:55 +01:00 committed by GitHub
parent 49b47fe648
commit 70d9e8a582
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 402 additions and 44 deletions

View File

@ -1,4 +1,7 @@
"""Support for deCONZ switches.""" """Support for deCONZ fans."""
from typing import Optional
from homeassistant.components.fan import ( from homeassistant.components.fan import (
DOMAIN, DOMAIN,
SPEED_HIGH, SPEED_HIGH,
@ -10,25 +13,19 @@ from homeassistant.components.fan import (
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect 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 .const import FANS, NEW_LIGHT
from .deconz_device import DeconzDevice from .deconz_device import DeconzDevice
from .gateway import get_gateway_from_config_entry from .gateway import get_gateway_from_config_entry
SPEEDS = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 2, SPEED_HIGH: 4} ORDERED_NAMED_FAN_SPEEDS = [1, 2, 3, 4]
SUPPORTED_ON_SPEEDS = {1: SPEED_LOW, 2: SPEED_MEDIUM, 4: SPEED_HIGH}
LEGACY_SPEED_TO_DECONZ = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 2, SPEED_HIGH: 4}
def convert_speed(speed: int) -> str: LEGACY_DECONZ_TO_SPEED = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 4: SPEED_HIGH}
"""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: async def async_setup_entry(hass, config_entry, async_add_entities) -> None:
@ -67,8 +64,8 @@ class DeconzFan(DeconzDevice, FanEntity):
"""Set up fan.""" """Set up fan."""
super().__init__(device, gateway) super().__init__(device, gateway)
self._default_on_speed = SPEEDS[SPEED_MEDIUM] self._default_on_speed = 2
if self.speed != SPEED_OFF: if self._device.speed in ORDERED_NAMED_FAN_SPEEDS:
self._default_on_speed = self._device.speed self._default_on_speed = self._device.speed
self._features = SUPPORT_SET_SPEED self._features = SUPPORT_SET_SPEED
@ -76,17 +73,58 @@ class DeconzFan(DeconzDevice, FanEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if fan is on.""" """Return true if fan is on."""
return self.speed != SPEED_OFF return self._device.speed != 0
@property @property
def speed(self) -> int: def percentage(self) -> Optional[int]:
"""Return the current speed.""" """Return the current speed percentage."""
return convert_speed(self._device.speed) 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 @property
def speed_list(self) -> list: def speed_list(self) -> list:
"""Get the list of available speeds.""" """Get the list of available speeds.
return list(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 @property
def supported_features(self) -> int: def supported_features(self) -> int:
@ -96,24 +134,26 @@ class DeconzFan(DeconzDevice, FanEntity):
@callback @callback
def async_update_callback(self, force_update=False) -> None: def async_update_callback(self, force_update=False) -> None:
"""Store latest configured speed from the device.""" """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 self._default_on_speed = self._device.speed
super().async_update_callback(force_update) 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: async def async_set_speed(self, speed: str) -> None:
"""Set the speed of the fan.""" """Set the speed of the fan.
if speed not in SPEEDS:
Legacy fan support.
"""
if speed not in LEGACY_SPEED_TO_DECONZ:
raise ValueError(f"Unsupported speed {speed}") 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( async def async_turn_on(
self, self,
speed: str = None, speed: str = None,
@ -122,10 +162,15 @@ class DeconzFan(DeconzDevice, FanEntity):
**kwargs, **kwargs,
) -> None: ) -> None:
"""Turn on fan.""" """Turn on fan."""
if not speed: new_speed = self._default_on_speed
speed = convert_speed(self._default_on_speed)
await self.async_set_speed(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: async def async_turn_off(self, **kwargs) -> None:
"""Turn off fan.""" """Turn off fan."""
await self.async_set_speed(SPEED_OFF) await self._device.set_speed(0)

View File

@ -5,8 +5,10 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant.components.fan import ( from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_SPEED, ATTR_SPEED,
DOMAIN as FAN_DOMAIN, DOMAIN as FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
SERVICE_SET_SPEED, SERVICE_SET_SPEED,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
SERVICE_TURN_ON, 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 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").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 # 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 = { event_changed_light = {
"t": "event", "t": "event",
"e": "changed", "e": "changed",
@ -74,13 +128,13 @@ async def test_fans(hass, aioclient_mock, mock_deconz_websocket):
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get("fan.ceiling_fan").state == STATE_OFF 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 # Test service calls
mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") 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( await hass.services.async_call(
FAN_DOMAIN, 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} 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 # Service set fan speed to low
await hass.services.async_call( 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}, {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_LOW},
blocking=True, 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 # 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}, {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_MEDIUM},
blocking=True, 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 # 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}, {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_HIGH},
blocking=True, 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 # 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}, {ATTR_ENTITY_ID: "fan.ceiling_fan", ATTR_SPEED: SPEED_OFF},
blocking=True, 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 # Service set fan speed to unsupported value