diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index bc5714ef08c..1cd66f94686 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -1,22 +1,26 @@ """Fans on Zigbee Home Automation networks.""" +from abc import abstractmethod import functools +import math from typing import List, Optional from zigpy.exceptions import ZigbeeException import zigpy.zcl.clusters.hvac as hvac from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, SUPPORT_SET_SPEED, FanEntity, ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import State, callback 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.const import ( @@ -32,24 +36,20 @@ from .entity import ZhaEntity, ZhaGroupEntity # Additional speeds in zigbee's ZCL # Spec is unclear as to what this value means. On King Of Fans HBUniversal # receiver, this means Very High. -SPEED_ON = "on" +PRESET_MODE_ON = "on" # 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 -SPEED_SMART = "smart" +PRESET_MODE_SMART = "smart" -SPEED_LIST = [ - SPEED_OFF, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_HIGH, - SPEED_ON, - SPEED_AUTO, - SPEED_SMART, -] +SPEED_RANGE = (1, 3) # off is not included +PRESET_MODES_TO_NAME = {4: PRESET_MODE_ON, 5: PRESET_MODE_AUTO, 6: PRESET_MODE_SMART} + +NAME_TO_PRESET_MODE = {v: k for k, v in PRESET_MODES_TO_NAME.items()} +PRESET_MODES = list(NAME_TO_PRESET_MODE) + +DEFAULT_ON_PERCENTAGE = 50 -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) 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): """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 - def speed_list(self) -> list: - """Get the list of available speeds.""" - return SPEED_LIST - - @property - def speed(self) -> str: - """Return the current speed.""" - return self._state + def preset_modes(self) -> str: + """Return the available preset modes.""" + return PRESET_MODES @property def supported_features(self) -> int: """Flag supported features.""" 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( self, speed=None, percentage=None, preset_mode=None, **kwargs ) -> None: """Turn the entity on.""" - if speed is None: - speed = SPEED_MEDIUM - - await self.async_set_speed(speed) + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: """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: - """Set the speed of the fan.""" - await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed]) - self.async_set_state(0, "fan_mode", speed) + 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) + + @abstractmethod + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" @callback def async_set_state(self, attr_id, attr_name, value): @@ -142,15 +132,32 @@ class ZhaFan(BaseFan, ZhaEntity): ) @property - def speed(self) -> Optional[str]: - """Return the current speed.""" - return VALUE_TO_SPEED.get(self._fan_channel.fan_mode) + def percentage(self) -> str: + """Return the current speed percentage.""" + 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 def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" 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() class FanGroup(BaseFan, ZhaGroupEntity): @@ -164,30 +171,60 @@ class FanGroup(BaseFan, ZhaGroupEntity): self._available: bool = False group = self.zha_device.gateway.get_group(self._group_id) self._fan_channel = group.endpoint[hvac.Fan.cluster_id] + self._percentage = None + self._preset_mode = None - # what should we do with this hack? - async def async_set_speed(value) -> None: - """Set the speed of the fan.""" - try: - await self._fan_channel.write_attributes({"fan_mode": value}) - except ZigbeeException as ex: - self.error("Could not set speed: %s", ex) - return + @property + def percentage(self) -> str: + """Return the current speed percentage.""" + return self._percentage - 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): """Attempt to retrieve on off state from the fan.""" all_states = [self.hass.states.get(x) for x in self._entity_ids] 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) - # for now just use first non off state since its kind of arbitrary - if not on_states: - self._state = SPEED_OFF + + if percentage_states: + 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: - self._state = on_states[0].state + self._percentage = None + self._preset_mode = None async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 61828c135bc..b6347ac6568 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, call, patch import pytest +from zigpy.exceptions import ZigbeeException import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general 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.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, DOMAIN, + SERVICE_SET_PRESET_MODE, SERVICE_SET_SPEED, SPEED_HIGH, SPEED_LOW, @@ -20,6 +24,11 @@ from homeassistant.components.fan import ( from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.zha.core.discovery import GROUP_PROBE 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 ( ATTR_ENTITY_ID, 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 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 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) +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( "zigpy.zcl.clusters.hvac.Fan.write_attributes", 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 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 await send_attributes_report(hass, dev1_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 +@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( - "plug_read, expected_state, expected_speed", + "plug_read, expected_state, expected_speed, expected_percentage", ( - (None, STATE_OFF, None), - ({"fan_mode": 0}, STATE_OFF, SPEED_OFF), - ({"fan_mode": 1}, STATE_ON, SPEED_LOW), - ({"fan_mode": 2}, STATE_ON, SPEED_MEDIUM), - ({"fan_mode": 3}, STATE_ON, SPEED_HIGH), + (None, STATE_OFF, None, None), + ({"fan_mode": 0}, STATE_OFF, SPEED_OFF, 0), + ({"fan_mode": 1}, STATE_ON, SPEED_LOW, 33), + ({"fan_mode": 2}, STATE_ON, SPEED_MEDIUM, 66), + ({"fan_mode": 3}, STATE_ON, SPEED_HIGH, 100), ), ) async def test_fan_init( @@ -313,6 +417,7 @@ async def test_fan_init( plug_read, expected_state, expected_speed, + expected_percentage, ): """Test zha fan platform.""" @@ -324,6 +429,8 @@ async def test_fan_init( assert entity_id is not None 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_PERCENTAGE] == expected_percentage + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None async def test_fan_update_entity( @@ -341,6 +448,8 @@ async def test_fan_update_entity( assert entity_id is not None 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_PERCENTAGE] == 0 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None assert cluster.read_attributes.await_count == 1 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 ) 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_PRESET_MODE] is None assert cluster.read_attributes.await_count == 3