From d73ab3229a20f61861f8f8a7f8a1d3b22b1cbb8e Mon Sep 17 00:00:00 2001 From: Adrian Freund Date: Tue, 16 Jul 2024 00:05:45 +0200 Subject: [PATCH] fan: 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%. --- tests/test_fan.py | 42 ++++++++---- zha/application/platforms/fan/__init__.py | 68 +++++++++++++------ .../cluster_handlers/manufacturerspecific.py | 10 ++- 3 files changed, 83 insertions(+), 37 deletions(-) diff --git a/tests/test_fan.py b/tests/test_fan.py index 97d5e036..b5ea802a 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -533,6 +533,13 @@ async def test_fan_ikea( call({"fan_mode": 1}, manufacturer=None) ] + # turn on with set speed from HA + cluster.write_attributes.reset_mock() + await async_turn_on(zha_gateway, entity, speed="high") + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 10}, manufacturer=None) + ] + # turn off from HA cluster.write_attributes.reset_mock() await async_turn_off(zha_gateway, entity) @@ -547,6 +554,13 @@ async def test_fan_ikea( call({"fan_mode": 10}, manufacturer=None) ] + # skip 10% when set from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(zha_gateway, entity, percentage=10) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 2}, 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) @@ -574,17 +588,17 @@ async def test_fan_ikea( ), [ (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"), + ({"fan_mode": 0, "fan_speed": 0}, False, 0, None), + ({"fan_mode": 1, "fan_speed": 6}, True, 60, PRESET_MODE_AUTO), + ({"fan_mode": 10, "fan_speed": 10}, True, 20, None), + ({"fan_mode": 15, "fan_speed": 15}, True, 30, None), + ({"fan_mode": 20, "fan_speed": 20}, True, 40, None), + ({"fan_mode": 25, "fan_speed": 25}, True, 50, None), + ({"fan_mode": 30, "fan_speed": 30}, True, 60, None), + ({"fan_mode": 35, "fan_speed": 35}, True, 70, None), + ({"fan_mode": 40, "fan_speed": 40}, True, 80, None), + ({"fan_mode": 45, "fan_speed": 45}, True, 90, None), + ({"fan_mode": 50, "fan_speed": 50}, True, 100, None), ], ) async def test_fan_ikea_init( @@ -613,7 +627,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 device_joined(zigpy_device_ikea) entity = get_entity(zha_device, platform=Platform.FAN) @@ -623,13 +637,13 @@ async def test_fan_ikea_update_entity( assert entity.state[ATTR_PRESET_MODE] is None assert entity.percentage_step == 100 / 10 - cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1, "fan_speed": 6} await entity.async_update() await zha_gateway.async_block_till_done() assert entity.state["is_on"] is True - assert entity.state[ATTR_PERCENTAGE] == 10 + assert entity.state[ATTR_PERCENTAGE] == 60 assert entity.state[ATTR_PRESET_MODE] is PRESET_MODE_AUTO assert entity.percentage_step == 100 / 10 diff --git a/zha/application/platforms/fan/__init__.py b/zha/application/platforms/fan/__init__.py index 765d1257..fa740228 100644 --- a/zha/application/platforms/fan/__init__.py +++ b/zha/application/platforms/fan/__init__.py @@ -378,21 +378,6 @@ def async_update(self, _: Any = None) -> None: self.maybe_emit_state_changed_event() -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"}, @@ -400,6 +385,13 @@ def async_update(self, _: Any = None) -> None: class IkeaFan(Fan): """Representation of an Ikea fan.""" + _attr_supported_features: FanEntityFeature = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + def __init__( self, unique_id: str, @@ -421,20 +413,54 @@ def __init__( @functools.cached_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} @functools.cached_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.""" + self.debug(f"fan_speed is {self._fan_cluster_handler.fan_speed}") + 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, + speed: str | None = None, + 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 speed is None and percentage is None and preset_mode is None: + await self.async_set_preset_mode("auto") + else: + await super().async_turn_on(speed, percentage, preset_mode) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + + self.debug(f"Set percentage {percentage}") + 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/zha/zigbee/cluster_handlers/manufacturerspecific.py b/zha/zigbee/cluster_handlers/manufacturerspecific.py index 2c8c73a2..5d2846f6 100644 --- a/zha/zigbee/cluster_handlers/manufacturerspecific.py +++ b/zha/zigbee/cluster_handlers/manufacturerspecific.py @@ -409,6 +409,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 mode.""" + return self.cluster.get("fan_speed") + @property def fan_mode_sequence(self) -> int | None: """Return possible fan mode speeds.""" @@ -421,6 +426,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) def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update from fan cluster.""" @@ -428,8 +434,8 @@ 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": - self.attribute_updated(attrid, attr_name, value) + if attr_name in ("fan_mode", "fan_speed"): + super().attribute_updated(attrid, attr_name, value) @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(IKEA_REMOTE_CLUSTER)