Update zha to use new fan entity model (#45758)

* Update zha to use new fan entity model

* fixes

* tweaks for zha

* pylint

* augment test cover
This commit is contained in:
J. Nick Koston 2021-02-17 08:03:11 -10:00 committed by GitHub
parent 4ab0151fb1
commit b2df9aaaf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 220 additions and 72 deletions

View File

@ -1,22 +1,26 @@
"""Fans on Zigbee Home Automation networks.""" """Fans on Zigbee Home Automation networks."""
from abc import abstractmethod
import functools import functools
import math
from typing import List, Optional from typing import List, Optional
from zigpy.exceptions import ZigbeeException from zigpy.exceptions import ZigbeeException
import zigpy.zcl.clusters.hvac as hvac import zigpy.zcl.clusters.hvac as hvac
from homeassistant.components.fan import ( from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
DOMAIN, DOMAIN,
SPEED_HIGH,
SPEED_LOW,
SPEED_MEDIUM,
SPEED_OFF,
SUPPORT_SET_SPEED, SUPPORT_SET_SPEED,
FanEntity, FanEntity,
) )
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import State, callback from homeassistant.core import State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from .core import discovery from .core import discovery
from .core.const import ( from .core.const import (
@ -32,24 +36,20 @@ from .entity import ZhaEntity, ZhaGroupEntity
# Additional speeds in zigbee's ZCL # Additional speeds in zigbee's ZCL
# Spec is unclear as to what this value means. On King Of Fans HBUniversal # Spec is unclear as to what this value means. On King Of Fans HBUniversal
# receiver, this means Very High. # receiver, this means Very High.
SPEED_ON = "on" PRESET_MODE_ON = "on"
# The fan speed is self-regulated # The fan speed is self-regulated
SPEED_AUTO = "auto" PRESET_MODE_AUTO = "auto"
# When the heated/cooled space is occupied, the fan is always on # When the heated/cooled space is occupied, the fan is always on
SPEED_SMART = "smart" PRESET_MODE_SMART = "smart"
SPEED_LIST = [ SPEED_RANGE = (1, 3) # off is not included
SPEED_OFF, PRESET_MODES_TO_NAME = {4: PRESET_MODE_ON, 5: PRESET_MODE_AUTO, 6: PRESET_MODE_SMART}
SPEED_LOW,
SPEED_MEDIUM, NAME_TO_PRESET_MODE = {v: k for k, v in PRESET_MODES_TO_NAME.items()}
SPEED_HIGH, PRESET_MODES = list(NAME_TO_PRESET_MODE)
SPEED_ON,
SPEED_AUTO, DEFAULT_ON_PERCENTAGE = 50
SPEED_SMART,
]
VALUE_TO_SPEED = dict(enumerate(SPEED_LIST))
SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)}
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, DOMAIN) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, DOMAIN)
@ -74,51 +74,41 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class BaseFan(FanEntity): class BaseFan(FanEntity):
"""Base representation of a ZHA fan.""" """Base representation of a ZHA fan."""
def __init__(self, *args, **kwargs):
"""Initialize the fan."""
super().__init__(*args, **kwargs)
self._state = None
self._fan_channel = None
@property @property
def speed_list(self) -> list: def preset_modes(self) -> str:
"""Get the list of available speeds.""" """Return the available preset modes."""
return SPEED_LIST return PRESET_MODES
@property
def speed(self) -> str:
"""Return the current speed."""
return self._state
@property @property
def supported_features(self) -> int: def supported_features(self) -> int:
"""Flag supported features.""" """Flag supported features."""
return SUPPORT_SET_SPEED return SUPPORT_SET_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, speed=None, percentage=None, preset_mode=None, **kwargs self, speed=None, percentage=None, preset_mode=None, **kwargs
) -> None: ) -> None:
"""Turn the entity on.""" """Turn the entity on."""
if speed is None: await self.async_set_percentage(percentage)
speed = SPEED_MEDIUM
await self.async_set_speed(speed)
async def async_turn_off(self, **kwargs) -> None: async def async_turn_off(self, **kwargs) -> None:
"""Turn the entity off.""" """Turn the entity off."""
await self.async_set_speed(SPEED_OFF) await self.async_set_percentage(0)
async def async_set_speed(self, speed: str) -> None: async def async_set_percentage(self, percentage: Optional[int]) -> None:
"""Set the speed of the fan.""" """Set the speed percenage of the fan."""
await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed]) if percentage is None:
self.async_set_state(0, "fan_mode", speed) percentage = DEFAULT_ON_PERCENTAGE
fan_mode = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
await self._async_set_fan_mode(fan_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the speed percenage of the fan."""
fan_mode = NAME_TO_PRESET_MODE.get(preset_mode)
await self._async_set_fan_mode(fan_mode)
@abstractmethod
async def _async_set_fan_mode(self, fan_mode: int) -> None:
"""Set the fan mode for the fan."""
@callback @callback
def async_set_state(self, attr_id, attr_name, value): def async_set_state(self, attr_id, attr_name, value):
@ -142,15 +132,32 @@ class ZhaFan(BaseFan, ZhaEntity):
) )
@property @property
def speed(self) -> Optional[str]: def percentage(self) -> str:
"""Return the current speed.""" """Return the current speed percentage."""
return VALUE_TO_SPEED.get(self._fan_channel.fan_mode) if (
self._fan_channel.fan_mode is None
or self._fan_channel.fan_mode > SPEED_RANGE[1]
):
return None
if self._fan_channel.fan_mode == 0:
return 0
return ranged_value_to_percentage(SPEED_RANGE, self._fan_channel.fan_mode)
@property
def preset_mode(self) -> str:
"""Return the current preset mode."""
return PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode)
@callback @callback
def async_set_state(self, attr_id, attr_name, value): def async_set_state(self, attr_id, attr_name, value):
"""Handle state update from channel.""" """Handle state update from channel."""
self.async_write_ha_state() self.async_write_ha_state()
async def _async_set_fan_mode(self, fan_mode: int) -> None:
"""Set the fan mode for the fan."""
await self._fan_channel.async_set_speed(fan_mode)
self.async_set_state(0, "fan_mode", fan_mode)
@GROUP_MATCH() @GROUP_MATCH()
class FanGroup(BaseFan, ZhaGroupEntity): class FanGroup(BaseFan, ZhaGroupEntity):
@ -164,30 +171,60 @@ class FanGroup(BaseFan, ZhaGroupEntity):
self._available: bool = False self._available: bool = False
group = self.zha_device.gateway.get_group(self._group_id) group = self.zha_device.gateway.get_group(self._group_id)
self._fan_channel = group.endpoint[hvac.Fan.cluster_id] self._fan_channel = group.endpoint[hvac.Fan.cluster_id]
self._percentage = None
self._preset_mode = None
# what should we do with this hack? @property
async def async_set_speed(value) -> None: def percentage(self) -> str:
"""Set the speed of the fan.""" """Return the current speed percentage."""
try: return self._percentage
await self._fan_channel.write_attributes({"fan_mode": value})
except ZigbeeException as ex:
self.error("Could not set speed: %s", ex)
return
self._fan_channel.async_set_speed = async_set_speed @property
def preset_mode(self) -> str:
"""Return the current preset mode."""
return self._preset_mode
async def async_set_percentage(self, percentage: Optional[int]) -> None:
"""Set the speed percenage of the fan."""
if percentage is None:
percentage = DEFAULT_ON_PERCENTAGE
fan_mode = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
await self._async_set_fan_mode(fan_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the speed percenage of the fan."""
fan_mode = NAME_TO_PRESET_MODE.get(preset_mode)
await self._async_set_fan_mode(fan_mode)
async def _async_set_fan_mode(self, fan_mode: int) -> None:
"""Set the fan mode for the group."""
try:
await self._fan_channel.write_attributes({"fan_mode": fan_mode})
except ZigbeeException as ex:
self.error("Could not set fan mode: %s", ex)
self.async_set_state(0, "fan_mode", fan_mode)
async def async_update(self): async def async_update(self):
"""Attempt to retrieve on off state from the fan.""" """Attempt to retrieve on off state from the fan."""
all_states = [self.hass.states.get(x) for x in self._entity_ids] all_states = [self.hass.states.get(x) for x in self._entity_ids]
states: List[State] = list(filter(None, all_states)) states: List[State] = list(filter(None, all_states))
on_states: List[State] = [state for state in states if state.state != SPEED_OFF] percentage_states: List[State] = [
state for state in states if state.attributes.get(ATTR_PERCENTAGE)
]
preset_mode_states: List[State] = [
state for state in states if state.attributes.get(ATTR_PRESET_MODE)
]
self._available = any(state.state != STATE_UNAVAILABLE for state in states) self._available = any(state.state != STATE_UNAVAILABLE for state in states)
# for now just use first non off state since its kind of arbitrary
if not on_states: if percentage_states:
self._state = SPEED_OFF self._percentage = percentage_states[0].attributes[ATTR_PERCENTAGE]
self._preset_mode = None
elif preset_mode_states:
self._preset_mode = preset_mode_states[0].attributes[ATTR_PRESET_MODE]
self._percentage = None
else: else:
self._state = on_states[0].state self._percentage = None
self._preset_mode = None
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass.""" """Run when about to be added to hass."""

View File

@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, call, patch from unittest.mock import AsyncMock, call, patch
import pytest import pytest
from zigpy.exceptions import ZigbeeException
import zigpy.profiles.zha as zha import zigpy.profiles.zha as zha
import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.hvac as hvac import zigpy.zcl.clusters.hvac as hvac
@ -9,8 +10,11 @@ import zigpy.zcl.foundation as zcl_f
from homeassistant.components import fan from homeassistant.components import fan
from homeassistant.components.fan import ( from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
ATTR_SPEED, ATTR_SPEED,
DOMAIN, DOMAIN,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_SPEED, SERVICE_SET_SPEED,
SPEED_HIGH, SPEED_HIGH,
SPEED_LOW, SPEED_LOW,
@ -20,6 +24,11 @@ from homeassistant.components.fan import (
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.zha.core.discovery import GROUP_PROBE from homeassistant.components.zha.core.discovery import GROUP_PROBE
from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.group import GroupMember
from homeassistant.components.zha.fan import (
PRESET_MODE_AUTO,
PRESET_MODE_ON,
PRESET_MODE_SMART,
)
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
@ -173,6 +182,12 @@ async def test_fan(hass, zha_device_joined_restored, zigpy_device):
assert len(cluster.write_attributes.mock_calls) == 1 assert len(cluster.write_attributes.mock_calls) == 1
assert cluster.write_attributes.call_args == call({"fan_mode": 3}) assert cluster.write_attributes.call_args == call({"fan_mode": 3})
# change preset_mode from HA
cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_ON)
assert len(cluster.write_attributes.mock_calls) == 1
assert cluster.write_attributes.call_args == call({"fan_mode": 4})
# test adding new fan to the network and HA # test adding new fan to the network and HA
await async_test_rejoin(hass, zigpy_device, [cluster], (1,)) await async_test_rejoin(hass, zigpy_device, [cluster], (1,))
@ -206,6 +221,17 @@ async def async_set_speed(hass, entity_id, speed=None):
await hass.services.async_call(DOMAIN, SERVICE_SET_SPEED, data, blocking=True) await hass.services.async_call(DOMAIN, SERVICE_SET_SPEED, data, blocking=True)
async def async_set_preset_mode(hass, entity_id, preset_mode=None):
"""Set preset_mode for specified fan."""
data = {
key: value
for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PRESET_MODE, preset_mode)]
if value is not None
}
await hass.services.async_call(DOMAIN, SERVICE_SET_PRESET_MODE, data, blocking=True)
@patch( @patch(
"zigpy.zcl.clusters.hvac.Fan.write_attributes", "zigpy.zcl.clusters.hvac.Fan.write_attributes",
new=AsyncMock(return_value=zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]), new=AsyncMock(return_value=zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]),
@ -276,6 +302,24 @@ async def test_zha_group_fan_entity(hass, device_fan_1, device_fan_2, coordinato
assert len(group_fan_cluster.write_attributes.mock_calls) == 1 assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 3} assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 3}
# change preset mode from HA
group_fan_cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_ON)
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 4}
# change preset mode from HA
group_fan_cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO)
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 5}
# change preset mode from HA
group_fan_cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_SMART)
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 6}
# test some of the group logic to make sure we key off states correctly # test some of the group logic to make sure we key off states correctly
await send_attributes_report(hass, dev1_fan_cluster, {0: 0}) await send_attributes_report(hass, dev1_fan_cluster, {0: 0})
await send_attributes_report(hass, dev2_fan_cluster, {0: 0}) await send_attributes_report(hass, dev2_fan_cluster, {0: 0})
@ -296,14 +340,74 @@ async def test_zha_group_fan_entity(hass, device_fan_1, device_fan_2, coordinato
assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id).state == STATE_OFF
@patch(
"zigpy.zcl.clusters.hvac.Fan.write_attributes",
new=AsyncMock(side_effect=ZigbeeException),
)
async def test_zha_group_fan_entity_failure_state(
hass, device_fan_1, device_fan_2, coordinator, caplog
):
"""Test the fan entity for a ZHA group when writing attributes generates an exception."""
zha_gateway = get_zha_gateway(hass)
assert zha_gateway is not None
zha_gateway.coordinator_zha_device = coordinator
coordinator._zha_gateway = zha_gateway
device_fan_1._zha_gateway = zha_gateway
device_fan_2._zha_gateway = zha_gateway
member_ieee_addresses = [device_fan_1.ieee, device_fan_2.ieee]
members = [GroupMember(device_fan_1.ieee, 1), GroupMember(device_fan_2.ieee, 1)]
# test creating a group with 2 members
zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 2
for member in zha_group.members:
assert member.device.ieee in member_ieee_addresses
assert member.group == zha_group
assert member.endpoint is not None
entity_domains = GROUP_PROBE.determine_entity_domains(hass, zha_group)
assert len(entity_domains) == 2
assert LIGHT_DOMAIN in entity_domains
assert DOMAIN in entity_domains
entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group)
assert hass.states.get(entity_id) is not None
group_fan_cluster = zha_group.endpoint[hvac.Fan.cluster_id]
await async_enable_traffic(hass, [device_fan_1, device_fan_2], enabled=False)
await hass.async_block_till_done()
# test that the fans were created and that they are unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [device_fan_1, device_fan_2])
# test that the fan group entity was created and is off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
group_fan_cluster.write_attributes.reset_mock()
await async_turn_on(hass, entity_id)
await hass.async_block_till_done()
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 2}
assert "Could not set fan mode" in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(
"plug_read, expected_state, expected_speed", "plug_read, expected_state, expected_speed, expected_percentage",
( (
(None, STATE_OFF, None), (None, STATE_OFF, None, None),
({"fan_mode": 0}, STATE_OFF, SPEED_OFF), ({"fan_mode": 0}, STATE_OFF, SPEED_OFF, 0),
({"fan_mode": 1}, STATE_ON, SPEED_LOW), ({"fan_mode": 1}, STATE_ON, SPEED_LOW, 33),
({"fan_mode": 2}, STATE_ON, SPEED_MEDIUM), ({"fan_mode": 2}, STATE_ON, SPEED_MEDIUM, 66),
({"fan_mode": 3}, STATE_ON, SPEED_HIGH), ({"fan_mode": 3}, STATE_ON, SPEED_HIGH, 100),
), ),
) )
async def test_fan_init( async def test_fan_init(
@ -313,6 +417,7 @@ async def test_fan_init(
plug_read, plug_read,
expected_state, expected_state,
expected_speed, expected_speed,
expected_percentage,
): ):
"""Test zha fan platform.""" """Test zha fan platform."""
@ -324,6 +429,8 @@ async def test_fan_init(
assert entity_id is not None assert entity_id is not None
assert hass.states.get(entity_id).state == expected_state assert hass.states.get(entity_id).state == expected_state
assert hass.states.get(entity_id).attributes[ATTR_SPEED] == expected_speed assert hass.states.get(entity_id).attributes[ATTR_SPEED] == expected_speed
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
async def test_fan_update_entity( async def test_fan_update_entity(
@ -341,6 +448,8 @@ async def test_fan_update_entity(
assert entity_id is not None assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_OFF assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_OFF
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
assert cluster.read_attributes.await_count == 1 assert cluster.read_attributes.await_count == 1
await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "homeassistant", {})
@ -358,5 +467,7 @@ async def test_fan_update_entity(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
) )
assert hass.states.get(entity_id).state == STATE_ON assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 33
assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_LOW assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_LOW
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
assert cluster.read_attributes.await_count == 3 assert cluster.read_attributes.await_count == 3