From 180f656d22cd94ecb406ed3dd0be1423948c21ce Mon Sep 17 00:00:00 2001 From: Adrian Freund Date: Thu, 4 Apr 2024 12:50:35 +0200 Subject: [PATCH] zha: ikea: starkvind: Fix capabilities and modes being incorrectly exposed Fixes https://github.com/home-assistant/core/issues/97440 Previously starkvind exposed 10 speed settings and no modes, where 10% corresponded to auto mode and 20%-100% corresponded to fixed speeds. This patch correctly exposes auto mode as a mode. It also adds support for showing the actual fan speed while auto mode is enabled. Starkvind supports 9 fan speeds. Because 9 doesn't neatly fit into 100% I cheated a bit and divided the 100% into 10% increments, where trying to set the fan to 10% sets it to 20% instead. I believe that this gives the overall better user experience compared to having 11.11% increments. The 5 speed modes present on the physical interface of the device correspond to HA speed settings 20%, 40%, 60% and 100%. This patch depends on https://github.com/zigpy/zha-device-handlers/pull/3088 being merged and released. --- .../cluster_handlers/manufacturerspecific.py | 8 ++- homeassistant/components/zha/fan.py | 62 ++++++++++++------- tests/components/zha/test_fan.py | 36 +++++------ 3 files changed, 66 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 9d5d68d2c7ef50..6c70a96ec32399 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -400,6 +400,11 @@ def fan_mode(self) -> int | None: """Return current fan mode.""" return self.cluster.get("fan_mode") + @property + def fan_speed(self) -> int | None: + """Return current fan speed.""" + return self.cluster.get("fan_speed") + @property def fan_mode_sequence(self) -> int | None: """Return possible fan mode speeds.""" @@ -412,6 +417,7 @@ async def async_set_speed(self, value) -> None: async def async_update(self) -> None: """Retrieve latest state.""" await self.get_attribute_value("fan_mode", from_cache=False) + await self.get_attribute_value("fan_speed", from_cache=False) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: @@ -420,7 +426,7 @@ def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) - if attr_name == "fan_mode": + if attr_name in ("fan_mode", "fan_speed"): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 3677befb76e346..3509a7868f2fa9 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -251,21 +251,6 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() -IKEA_SPEED_RANGE = (1, 10) # off is not included -IKEA_PRESET_MODES_TO_NAME = { - 1: PRESET_MODE_AUTO, - 2: "Speed 1", - 3: "Speed 1.5", - 4: "Speed 2", - 5: "Speed 2.5", - 6: "Speed 3", - 7: "Speed 3.5", - 8: "Speed 4", - 9: "Speed 4.5", - 10: "Speed 5", -} - - @MULTI_MATCH( cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, @@ -273,6 +258,8 @@ async def async_added_to_hass(self) -> None: class IkeaFan(ZhaFan): """Representation of an Ikea fan.""" + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: """Init this sensor.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @@ -281,20 +268,53 @@ def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: @property def preset_modes_to_name(self) -> dict[int, str]: """Return a dict from preset mode to name.""" - return IKEA_PRESET_MODES_TO_NAME + return {1: PRESET_MODE_AUTO} @property def speed_range(self) -> tuple[int, int]: """Return the range of speeds the fan supports. Off is not included.""" - return IKEA_SPEED_RANGE + + # 1 is not a speed, but auto mode and is filtered out in async_set_percentage + return (1, 10) @property - def default_on_percentage(self) -> int: - """Return the default on percentage.""" - return int( - (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] + def percentage(self) -> int | None: + """Return the current speed percentage.""" + if self._fan_cluster_handler.fan_speed is None: + return None + if self._fan_cluster_handler.fan_speed == 0: + return 0 + return ranged_value_to_percentage( + # Starkvind has an additional fan_speed attribute that we can use to + # get the speed even if fan_mode is set to auto. + self.speed_range, + self._fan_cluster_handler.fan_speed, ) + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the entity on.""" + # Starkvind turns on in auto mode by default. + if percentage is None: + if preset_mode is None: + preset_mode = "auto" + await self.async_set_preset_mode(preset_mode) + else: + await self.async_set_percentage(percentage) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + + fan_mode = math.ceil(percentage_to_ranged_value(self.speed_range, percentage)) + # 1 is a mode, not a speed, so we skip to 2 instead. + if fan_mode == 1: + fan_mode = 2 + await self._async_set_fan_mode(fan_mode) + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_FAN, diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 095f505876e161..24a5923766c8b9 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -647,17 +647,17 @@ async def test_fan_ikea( ), [ (None, STATE_OFF, None, None), - ({"fan_mode": 0}, STATE_OFF, 0, None), - ({"fan_mode": 1}, STATE_ON, 10, PRESET_MODE_AUTO), - ({"fan_mode": 10}, STATE_ON, 20, "Speed 1"), - ({"fan_mode": 15}, STATE_ON, 30, "Speed 1.5"), - ({"fan_mode": 20}, STATE_ON, 40, "Speed 2"), - ({"fan_mode": 25}, STATE_ON, 50, "Speed 2.5"), - ({"fan_mode": 30}, STATE_ON, 60, "Speed 3"), - ({"fan_mode": 35}, STATE_ON, 70, "Speed 3.5"), - ({"fan_mode": 40}, STATE_ON, 80, "Speed 4"), - ({"fan_mode": 45}, STATE_ON, 90, "Speed 4.5"), - ({"fan_mode": 50}, STATE_ON, 100, "Speed 5"), + ({"fan_mode": 0, "fan_speed": 0}, STATE_OFF, 0, None), + ({"fan_mode": 1, "fan_speed": 6}, STATE_ON, 60, PRESET_MODE_AUTO), + ({"fan_mode": 2, "fan_speed": 2}, STATE_ON, 20, None), + ({"fan_mode": 3, "fan_speed": 3}, STATE_ON, 30, None), + ({"fan_mode": 4, "fan_speed": 4}, STATE_ON, 40, None), + ({"fan_mode": 5, "fan_speed": 5}, STATE_ON, 50, None), + ({"fan_mode": 6, "fan_speed": 6}, STATE_ON, 60, None), + ({"fan_mode": 7, "fan_speed": 7}, STATE_ON, 70, None), + ({"fan_mode": 8, "fan_speed": 8}, STATE_ON, 80, None), + ({"fan_mode": 9, "fan_speed": 9}, STATE_ON, 90, None), + ({"fan_mode": 10, "fan_speed": 10}, STATE_ON, 100, None), ], ) async def test_fan_ikea_init( @@ -691,7 +691,7 @@ async def test_fan_ikea_update_entity( ) -> None: """Test ZHA fan platform.""" cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier - cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0, "fan_speed": 0} zha_device = await zha_device_joined_restored(zigpy_device_ikea) entity_id = find_entity_id(Platform.FAN, zha_device, hass) @@ -713,22 +713,22 @@ async def test_fan_ikea_update_entity( ) assert hass.states.get(entity_id).state == STATE_OFF if zha_device_joined_restored.name == "zha_device_joined": - assert cluster.read_attributes.await_count == 4 + assert cluster.read_attributes.await_count == 5 else: - assert cluster.read_attributes.await_count == 7 + assert cluster.read_attributes.await_count == 8 - cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1, "fan_speed": 6} 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] == 10 + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 60 assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is PRESET_MODE_AUTO assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 10 if zha_device_joined_restored.name == "zha_device_joined": - assert cluster.read_attributes.await_count == 5 + assert cluster.read_attributes.await_count == 7 else: - assert cluster.read_attributes.await_count == 8 + assert cluster.read_attributes.await_count == 10 @pytest.fixture