Skip to content

Commit

Permalink
fan: starkvind: Fix capabilities and modes being incorrectly exposed
Browse files Browse the repository at this point in the history
Fixes home-assistant/core#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%.
  • Loading branch information
freundTech committed Jul 16, 2024
1 parent 3a1e0d6 commit 80d5bee
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 42 deletions.
29 changes: 15 additions & 14 deletions tests/test_fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,17 +574,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(
Expand All @@ -601,6 +601,7 @@ async def test_fan_ikea_init(

zha_device = await device_joined(zigpy_device_ikea)
entity = get_entity(zha_device, platform=Platform.FAN)
print(entity.state)
assert entity.state["is_on"] == ikea_expected_state
assert entity.state["percentage"] == ikea_expected_percentage
assert entity.state["preset_mode"] == ikea_preset_mode
Expand All @@ -613,7 +614,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)
Expand All @@ -623,13 +624,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

Expand Down
72 changes: 48 additions & 24 deletions zha/application/platforms/fan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
PRESET_MODES_TO_NAME,
SPEED_OFF,
SPEED_RANGE,
SUPPORT_SET_SPEED,
FanEntityFeature,
)
from zha.application.platforms.fan.helpers import (
Expand Down Expand Up @@ -75,7 +74,6 @@ class BaseFan(BaseEntity):

PLATFORM = Platform.FAN

_attr_supported_features = FanEntityFeature.SET_SPEED
_attr_translation_key: str = "fan"

@functools.cached_property
Expand Down Expand Up @@ -111,7 +109,7 @@ def speed_count(self) -> int:
@functools.cached_property
def supported_features(self) -> int:
"""Flag supported features."""
return SUPPORT_SET_SPEED
return FanEntityFeature.SET_SPEED

@property
def is_on(self) -> bool:
Expand Down Expand Up @@ -375,28 +373,15 @@ 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"},
)
class IkeaFan(Fan):
"""Representation of an Ikea fan."""

_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE

def __init__(
self,
unique_id: str,
Expand All @@ -415,23 +400,62 @@ def __init__(
self.handle_cluster_handler_attribute_updated,
)

@functools.cached_property
def supported_features(self) -> int:
return FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE

@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,
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."""

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,
Expand Down
2 changes: 0 additions & 2 deletions zha/application/platforms/fan/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@
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"
SPEED_LOW: Final[str] = "low"
SPEED_MEDIUM: Final[str] = "medium"
Expand Down
10 changes: 8 additions & 2 deletions zha/zigbee/cluster_handlers/manufacturerspecific.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -421,15 +426,16 @@ 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."""
attr_name = self._get_attribute_name(attrid)
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)
Expand Down

0 comments on commit 80d5bee

Please sign in to comment.