diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index b8cf2cd0339..05bf3469c7b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -299,3 +299,23 @@ class IkeaFan(ZhaFan): return int( (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_FAN, + models={"HBUniversalCFRemote", "HDC52EastwindFan"}, +) +class KofFan(ZhaFan): + """Representation of a fan made by King Of Fans.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return (1, 4) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return {6: PRESET_MODE_SMART} diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 81ab1c2e0f5..737604482d8 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -76,7 +76,7 @@ def fan_platform_only(): @pytest.fixture def zigpy_device(zigpy_device_mock): - """Device tracker zigpy device.""" + """Fan zigpy device.""" endpoints = { 1: { SIG_EP_INPUT: [hvac.Fan.cluster_id], @@ -540,7 +540,7 @@ async def test_fan_update_entity( @pytest.fixture def zigpy_device_ikea(zigpy_device_mock): - """Device tracker zigpy device.""" + """Ikea fan zigpy device.""" endpoints = { 1: { SIG_EP_INPUT: [ @@ -725,3 +725,179 @@ async def test_fan_ikea_update_entity( assert cluster.read_attributes.await_count == 5 else: assert cluster.read_attributes.await_count == 8 + + +@pytest.fixture +def zigpy_device_kof(zigpy_device_mock): + """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( + hass: HomeAssistant, + zha_device_joined_restored: ZHADevice, + zigpy_device_kof: Device, +) -> None: + """Test ZHA fan platform for King of Fans.""" + zha_device = await zha_device_joined_restored(zigpy_device_kof) + cluster = zigpy_device_kof.endpoints.get(1).fan + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_OFF + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the fan was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on at fan + await send_attributes_report(hass, cluster, {1: 2, 0: 1, 2: 3}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn off at fan + await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(hass, entity_id) + 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(hass, entity_id) + 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(hass, entity_id, 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(hass, entity_id, 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(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 0 + + # test adding new fan to the network and HA + await async_test_rejoin(hass, zigpy_device_kof, [cluster], (1,)) + + +@pytest.mark.parametrize( + ("plug_read", "expected_state", "expected_percentage", "expected_preset"), + ( + (None, STATE_OFF, None, None), + ({"fan_mode": 0}, STATE_OFF, 0, None), + ({"fan_mode": 1}, STATE_ON, 25, None), + ({"fan_mode": 2}, STATE_ON, 50, None), + ({"fan_mode": 3}, STATE_ON, 75, None), + ({"fan_mode": 4}, STATE_ON, 100, None), + ({"fan_mode": 6}, STATE_ON, None, PRESET_MODE_SMART), + ), +) +async def test_fan_kof_init( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device_kof, + plug_read, + expected_state, + expected_percentage, + expected_preset, +) -> 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 zha_device_joined_restored(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == expected_state + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == expected_preset + + +async def test_fan_kof_update_entity( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device_kof, +) -> 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 zha_device_joined_restored(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 2 + else: + assert cluster.read_attributes.await_count == 4 + + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 3 + else: + assert cluster.read_attributes.await_count == 5 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + await hass.services.async_call( + "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] == 25 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 4 + else: + assert cluster.read_attributes.await_count == 6 diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 842110ace87..44f01555b19 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1831,7 +1831,7 @@ DEVICES = [ }, ("fan", "00:11:22:33:44:55:66:77-1-514"): { DEV_SIG_CLUSTER_HANDLERS: ["fan"], - DEV_SIG_ENT_MAP_CLASS: "ZhaFan", + DEV_SIG_ENT_MAP_CLASS: "KofFan", DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_fan", }, },