From 545a3a01fb1fca46571e4feaa1a836acfce4e61e Mon Sep 17 00:00:00 2001 From: David Mulcahey Date: Fri, 22 Mar 2024 11:08:35 -0400 Subject: [PATCH] more fan coverage --- tests/test_fan.py | 330 +++++++++++++++++++++- zha/application/platforms/fan/__init__.py | 62 ++-- zha/application/platforms/fan/const.py | 5 + 3 files changed, 371 insertions(+), 26 deletions(-) diff --git a/tests/test_fan.py b/tests/test_fan.py index 0242836d..0fbd0aae 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -9,6 +9,7 @@ import pytest from slugify import slugify +import zhaquirks from zigpy.device import Device as ZigpyDevice from zigpy.exceptions import ZigbeeException from zigpy.profiles import zha @@ -19,6 +20,9 @@ from zha.application.gateway import ZHAGateway from zha.application.platforms import GroupEntity, PlatformEntity from zha.application.platforms.fan.const import ( + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, PRESET_MODE_AUTO, PRESET_MODE_ON, PRESET_MODE_SMART, @@ -27,6 +31,7 @@ SPEED_MEDIUM, SPEED_OFF, ) +from zha.application.platforms.fan.helpers import NotValidPresetModeError from zha.exceptions import ZHAException from zha.zigbee.device import ZHADevice from zha.zigbee.group import Group, GroupMemberReference @@ -209,7 +214,7 @@ async def test_fan( # set invalid preset_mode from client cluster.write_attributes.reset_mock() - with pytest.raises(KeyError, match="invalid"): + with pytest.raises(NotValidPresetModeError): await entity.async_set_preset_mode("invalid") assert len(cluster.write_attributes.mock_calls) == 0 @@ -252,6 +257,14 @@ async def async_set_speed( await zha_gateway.async_block_till_done() +async def async_set_percentage( + zha_gateway: ZHAGateway, entity: PlatformEntity, percentage=None +): + """Set percentage for specified fan.""" + await entity.async_set_percentage(percentage) + await zha_gateway.async_block_till_done() + + async def async_set_preset_mode( zha_gateway: ZHAGateway, entity: PlatformEntity, @@ -488,3 +501,318 @@ async def test_fan_update_entity( assert entity.get_state()["preset_mode"] is None assert entity.percentage_step == 100 / 3 assert cluster.read_attributes.await_count == 4 + + +@pytest.fixture +def zigpy_device_ikea(zigpy_device_mock) -> ZigpyDevice: + """Ikea fan zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="IKEA of Sweden", + model="STARKVIND Air purifier", + quirk=zhaquirks.ikea.starkvind.IkeaSTARKVIND, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_ikea( + zha_gateway: ZHAGateway, + device_joined: Callable[[ZigpyDevice], Awaitable[ZHADevice]], + zigpy_device_ikea: ZigpyDevice, +) -> None: + """Test ZHA fan Ikea platform.""" + zha_device = await device_joined(zigpy_device_ikea) + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + + # turn on at fan + await send_attributes_report(zha_gateway, cluster, {6: 1}) + assert entity.get_state()["is_on"] is True + + # turn off at fan + await send_attributes_report(zha_gateway, cluster, {6: 0}) + assert entity.get_state()["is_on"] is False + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(zha_gateway, entity) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 1}, manufacturer=None) + ] + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(zha_gateway, entity) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(zha_gateway, entity, percentage=100) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 10}, manufacturer=None) + ] + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_AUTO) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 1}, manufacturer=None) + ] + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode( + zha_gateway, + entity, + preset_mode="invalid does not exist", + ) + assert len(cluster.write_attributes.mock_calls) == 0 + + +@pytest.mark.parametrize( + ( + "ikea_plug_read", + "ikea_expected_state", + "ikea_expected_percentage", + "ikea_preset_mode", + ), + [ + (None, False, None, None), + ({"fan_mode": 0}, False, 0, None), + ({"fan_mode": 1}, True, 10, PRESET_MODE_AUTO), + ({"fan_mode": 10}, True, 20, "Speed 1"), + ({"fan_mode": 15}, True, 30, "Speed 1.5"), + ({"fan_mode": 20}, True, 40, "Speed 2"), + ({"fan_mode": 25}, True, 50, "Speed 2.5"), + ({"fan_mode": 30}, True, 60, "Speed 3"), + ({"fan_mode": 35}, True, 70, "Speed 3.5"), + ({"fan_mode": 40}, True, 80, "Speed 4"), + ({"fan_mode": 45}, True, 90, "Speed 4.5"), + ({"fan_mode": 50}, True, 100, "Speed 5"), + ], +) +async def test_fan_ikea_init( + device_joined: Callable[[ZigpyDevice], Awaitable[ZHADevice]], + zigpy_device_ikea: ZigpyDevice, + ikea_plug_read: dict, + ikea_expected_state: bool, + ikea_expected_percentage: int, + ikea_preset_mode: Optional[str], +) -> None: + """Test ZHA fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = ikea_plug_read + + zha_device = await device_joined(zigpy_device_ikea) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.get_state()["is_on"] == ikea_expected_state + assert entity.get_state()["percentage"] == ikea_expected_percentage + assert entity.get_state()["preset_mode"] == ikea_preset_mode + + +async def test_fan_ikea_update_entity( + zha_gateway: ZHAGateway, + device_joined: Callable[[ZigpyDevice], Awaitable[ZHADevice]], + zigpy_device_ikea: ZigpyDevice, +) -> None: + """Test ZHA fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await device_joined(zigpy_device_ikea) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + assert entity.get_state()[ATTR_PERCENTAGE] == 0 + assert entity.get_state()[ATTR_PRESET_MODE] is None + assert entity.to_json()[ATTR_PERCENTAGE_STEP] == 100 / 10 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + + await entity.async_update() + await zha_gateway.async_block_till_done() + + assert entity.get_state()["is_on"] is True + assert entity.get_state()[ATTR_PERCENTAGE] == 10 + assert entity.get_state()[ATTR_PRESET_MODE] is PRESET_MODE_AUTO + assert entity.to_json()[ATTR_PERCENTAGE_STEP] == 100 / 10 + + +@pytest.fixture +def zigpy_device_kof(zigpy_device_mock) -> ZigpyDevice: + """Fan by King of Fans zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="King Of Fans, Inc.", + model="HBUniversalCFRemote", + quirk=zhaquirks.kof.kof_mr101z.CeilingFan, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_kof( + zha_gateway: ZHAGateway, + device_joined: Callable[[ZigpyDevice], Awaitable[ZHADevice]], + zigpy_device_kof: ZigpyDevice, +) -> None: + """Test ZHA fan platform for King of Fans.""" + zha_device = await device_joined(zigpy_device_kof) + cluster = zigpy_device_kof.endpoints.get(1).fan + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + + # turn on at fan + await send_attributes_report(zha_gateway, cluster, {1: 2, 0: 1, 2: 3}) + assert entity.get_state()["is_on"] is True + + # turn off at fan + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 0, 2: 2}) + assert entity.get_state()["is_on"] is False + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(zha_gateway, entity) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 2}, manufacturer=None) + ] + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(zha_gateway, entity) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(zha_gateway, entity, percentage=100) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 4}, manufacturer=None) + ] + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_SMART) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 6}, manufacturer=None) + ] + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("plug_read", "expected_state", "expected_percentage", "expected_preset"), + [ + (None, False, None, None), + ({"fan_mode": 0}, False, 0, None), + ({"fan_mode": 1}, True, 25, None), + ({"fan_mode": 2}, True, 50, None), + ({"fan_mode": 3}, True, 75, None), + ({"fan_mode": 4}, True, 100, None), + ({"fan_mode": 6}, True, None, PRESET_MODE_SMART), + ], +) +async def test_fan_kof_init( + device_joined: Callable[[ZigpyDevice], Awaitable[ZHADevice]], + zigpy_device_kof: ZigpyDevice, + plug_read: dict, + expected_state: bool, + expected_percentage: Optional[int], + expected_preset: Optional[str], +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = plug_read + + zha_device = await device_joined(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is expected_state + assert entity.get_state()[ATTR_PERCENTAGE] == expected_percentage + assert entity.get_state()[ATTR_PRESET_MODE] == expected_preset + + +async def test_fan_kof_update_entity( + zha_gateway: ZHAGateway, + device_joined: Callable[[ZigpyDevice], Awaitable[ZHADevice]], + zigpy_device_kof: ZigpyDevice, +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await device_joined(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + assert entity.get_state()[ATTR_PERCENTAGE] == 0 + assert entity.get_state()[ATTR_PRESET_MODE] is None + assert entity.to_json()[ATTR_PERCENTAGE_STEP] == 100 / 4 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + + await entity.async_update() + await zha_gateway.async_block_till_done() + + assert entity.get_state()["is_on"] is True + assert entity.get_state()[ATTR_PERCENTAGE] == 25 + assert entity.get_state()[ATTR_PRESET_MODE] is None + assert entity.to_json()[ATTR_PERCENTAGE_STEP] == 100 / 4 diff --git a/zha/application/platforms/fan/__init__.py b/zha/application/platforms/fan/__init__.py index 2f0960ac..71cf9c3e 100644 --- a/zha/application/platforms/fan/__init__.py +++ b/zha/application/platforms/fan/__init__.py @@ -26,6 +26,7 @@ FanEntityFeature, ) from zha.application.platforms.fan.helpers import ( + NotValidPresetModeError, int_states_in_range, ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -64,14 +65,19 @@ def preset_modes(self) -> list[str]: return list(self.preset_modes_to_name.values()) @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_SET_SPEED + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return PRESET_MODES_TO_NAME @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) + def preset_name_to_mode(self) -> dict[str, int]: + """Return a dict from preset name to mode.""" + return {v: k for k, v in self.preset_modes_to_name.items()} + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return DEFAULT_ON_PERCENTAGE @property def speed_range(self) -> tuple[int, int]: @@ -79,19 +85,19 @@ def speed_range(self) -> tuple[int, int]: return SPEED_RANGE @property - def is_on(self) -> bool: - """Return true if the entity is on.""" - return self.speed not in [SPEED_OFF, None] # pylint: disable=no-member + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(self.speed_range) @property - def preset_modes_to_name(self) -> dict[int, str]: - """Return a dict from preset mode to name.""" - return PRESET_MODES_TO_NAME + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED @property - def preset_name_to_mode(self) -> dict[str, int]: - """Return a dict from preset name to mode.""" - return {v: k for k, v in self.preset_modes_to_name.items()} + def is_on(self) -> bool: + """Return true if the entity is on.""" + return self.speed not in [SPEED_OFF, None] # pylint: disable=no-member @property def percentage_step(self) -> float: @@ -106,11 +112,6 @@ def speed_list(self) -> list[str]: speeds.extend(preset_modes) return speeds - @property - def default_on_percentage(self) -> int: - """Return the default on percentage.""" - return DEFAULT_ON_PERCENTAGE - async def async_turn_on( # pylint: disable=unused-argument self, speed: str | None = None, @@ -126,7 +127,7 @@ async def async_turn_on( # pylint: disable=unused-argument elif percentage is not None: await self.async_set_percentage(percentage) else: - percentage = DEFAULT_ON_PERCENTAGE + percentage = self.default_on_percentage await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: # pylint: disable=unused-argument @@ -140,7 +141,13 @@ async def async_set_percentage(self, percentage: int) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the fan.""" - await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) + try: + mode = self.preset_name_to_mode[preset_mode] + except KeyError as ex: + raise NotValidPresetModeError( + f"{preset_mode} is not a valid preset mode" + ) from ex + await self._async_set_fan_mode(mode) @abstractmethod async def _async_set_fan_mode(self, fan_mode: int) -> None: @@ -175,6 +182,7 @@ def to_json(self) -> dict: json["speed_count"] = self.speed_count json["speed_list"] = self.speed_list json["percentage_step"] = self.percentage_step + json["default_on_percentage"] = self.default_on_percentage return json @@ -195,9 +203,10 @@ def __init__( self._fan_cluster_handler: ClusterHandler = self.cluster_handlers.get( CLUSTER_HANDLER_FAN ) - self._fan_cluster_handler.on_event( - CLUSTER_HANDLER_EVENT, self._handle_event_protocol - ) + if self._fan_cluster_handler: + self._fan_cluster_handler.on_event( + CLUSTER_HANDLER_EVENT, self._handle_event_protocol + ) @property def percentage(self) -> int | None: @@ -366,6 +375,9 @@ def __init__( self._fan_cluster_handler: ClusterHandler = self.cluster_handlers.get( "ikea_airpurifier" ) + self._fan_cluster_handler.on_event( + CLUSTER_HANDLER_EVENT, self._handle_event_protocol + ) @property def preset_modes_to_name(self) -> dict[int, str]: diff --git a/zha/application/platforms/fan/const.py b/zha/application/platforms/fan/const.py index 470212d0..3f703797 100644 --- a/zha/application/platforms/fan/const.py +++ b/zha/application/platforms/fan/const.py @@ -25,7 +25,12 @@ DEFAULT_ON_PERCENTAGE: Final[int] = 50 ATTR_PERCENTAGE: Final[str] = "percentage" +ATTR_PERCENTAGE_STEP: Final[str] = "percentage_step" +ATTR_OSCILLATING: Final[str] = "oscillating" +ATTR_DIRECTION: Final[str] = "direction" ATTR_PRESET_MODE: Final[str] = "preset_mode" +ATTR_PRESET_MODES: Final[str] = "preset_modes" + SUPPORT_SET_SPEED: Final[int] = 1 SPEED_OFF: Final[str] = "off"