From fa678d04085f443934c388ae2c6b43da3e5461d4 Mon Sep 17 00:00:00 2001 From: Arne Mauer Date: Wed, 29 Jun 2022 17:44:40 +0200 Subject: [PATCH] New sensors and manufacturer cluster to support IKEA STARKVIND (with Quirk) (#73450) * Add Particulate Matter 2.5 of ZCL concentration clusters to ZHA component * Fixed black and flake8 test * New sensors and manufacturer cluster to support IKEA STARKVIND (with quirk) * Isort and codespell fixes * Instead using the fan cluster, i've created a Ikea air purifier cluster/channel that supports all sensors and fan modes * update sensors to support the new ikea_airpurifier channel * Fix black, flake8, isort * Mylint/mypy fixes + Use a TypedDict for REPORT_CONFIG in zha #73629 * Last fix for test_fan.py * fix fan test Co-authored-by: David F. Mulcahey --- homeassistant/components/zha/binary_sensor.py | 8 + .../zha/core/channels/manufacturerspecific.py | 58 +++++- .../components/zha/core/registries.py | 1 + homeassistant/components/zha/fan.py | 99 ++++++++++ homeassistant/components/zha/number.py | 18 ++ homeassistant/components/zha/sensor.py | 32 +++ homeassistant/components/zha/switch.py | 18 ++ tests/components/zha/test_fan.py | 186 +++++++++++++++++- 8 files changed, 416 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 8130e7d5f98..709515d7ca2 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -186,3 +186,11 @@ class FrostLock(BinarySensor, id_suffix="frost_lock"): SENSOR_ATTR = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK + + +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) +class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): + """ZHA BinarySensor.""" + + SENSOR_ATTR = "replace_filter" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 101db65a66e..943d13a57d6 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -2,8 +2,9 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +from zigpy.exceptions import ZigbeeException import zigpy.zcl from homeassistant.core import callback @@ -14,6 +15,8 @@ from ..const import ( ATTR_ATTRIBUTE_NAME, ATTR_VALUE, REPORT_CONFIG_ASAP, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, SIGNAL_ATTR_UPDATED, @@ -129,3 +132,56 @@ class InovelliCluster(ClientChannel): """Inovelli Button Press Event channel.""" REPORT_CONFIG = () + + +@registries.CHANNEL_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +class IkeaAirPurifierChannel(ZigbeeChannel): + """IKEA Air Purifier channel.""" + + REPORT_CONFIG = ( + AttrReportConfig(attr="filter_run_time", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="replace_filter", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="filter_life_time", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="disable_led", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="air_quality_25pm", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="child_lock", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="fan_mode", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="fan_speed", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="device_run_time", config=REPORT_CONFIG_DEFAULT), + ) + + @property + def fan_mode(self) -> int | None: + """Return current fan mode.""" + return self.cluster.get("fan_mode") + + @property + def fan_mode_sequence(self) -> int | None: + """Return possible fan mode speeds.""" + return self.cluster.get("fan_mode_sequence") + + async def async_set_speed(self, value) -> None: + """Set the speed of the fan.""" + + try: + await self.cluster.write_attributes({"fan_mode": value}) + except ZigbeeException as ex: + self.error("Could not set speed: %s", ex) + return + + async def async_update(self) -> None: + """Retrieve latest state.""" + await self.get_attribute_value("fan_mode", from_cache=False) + + @callback + def attribute_updated(self, attrid: int, value: 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.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index d271f2ecba3..2480cf1cd43 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -28,6 +28,7 @@ _ZhaGroupEntityT = TypeVar("_ZhaGroupEntityT", bound=type["ZhaGroupEntity"]) GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] +IKEA_AIR_PURIFIER_CLUSTER = 0xFC7D PHILLIPS_REMOTE_CLUSTER = 0xFC00 SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 8e24427b679..d947fca10ab 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -51,6 +51,7 @@ DEFAULT_ON_PERCENTAGE = 50 STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.FAN) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.FAN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.FAN) async def async_setup_entry( @@ -228,3 +229,101 @@ class FanGroup(BaseFan, ZhaGroupEntity): """Run when about to be added to hass.""" await self.async_update() 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", +} +IKEA_NAME_TO_PRESET_MODE = {v: k for k, v in IKEA_PRESET_MODES_TO_NAME.items()} +IKEA_PRESET_MODES = list(IKEA_NAME_TO_PRESET_MODE) + + +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) +class IkeaFan(BaseFan, ZhaEntity): + """Representation of a ZHA fan.""" + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._fan_channel = self.cluster_channels.get("ikea_airpurifier") + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @property + def preset_modes(self) -> list[str]: + """Return the available preset modes.""" + return IKEA_PRESET_MODES + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(IKEA_SPEED_RANGE) + + async def async_set_percentage(self, percentage: int | None) -> None: + """Set the speed percenage of the fan.""" + if percentage is None: + percentage = 0 + fan_mode = math.ceil(percentage_to_ranged_value(IKEA_SPEED_RANGE, percentage)) + await self._async_set_fan_mode(fan_mode) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode for the fan.""" + if preset_mode not in self.preset_modes: + raise NotValidPresetModeError( + f"The preset_mode {preset_mode} is not a valid preset_mode: {self.preset_modes}" + ) + await self._async_set_fan_mode(IKEA_NAME_TO_PRESET_MODE[preset_mode]) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + if ( + self._fan_channel.fan_mode is None + or self._fan_channel.fan_mode > IKEA_SPEED_RANGE[1] + ): + return None + if self._fan_channel.fan_mode == 0: + return 0 + return ranged_value_to_percentage(IKEA_SPEED_RANGE, self._fan_channel.fan_mode) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return IKEA_PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) + + async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs) -> None: + """Turn the entity on.""" + if percentage is None: + percentage = (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[ + PRESET_MODE_AUTO + ] + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + await self.async_set_percentage(0) + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Handle state update from channel.""" + self.async_write_ha_state() + + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" + await self._fan_channel.async_set_speed(fan_mode) + self.async_set_state(0, "fan_mode", fan_mode) diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 9103dd2e364..c3d7f352318 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -523,3 +523,21 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati _attr_native_max_value: float = 0x257 _attr_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "timer_duration" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_manufacturer", + manufacturers={ + "IKEA of Sweden", + }, + models={"STARKVIND Air purifier"}, +) +class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): + """Representation of a ZHA timer duration configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFFFFFFFF + _attr_unit_of_measurement: str | None = UNITS[72] + _zcl_attribute: str = "filter_life_time" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index e66f1569b81..2fe38193ecb 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -808,3 +808,35 @@ class TimeLeft(Sensor, id_suffix="time_left"): _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" _unit = TIME_MINUTES + + +@MULTI_MATCH( + channel_names="ikea_manufacturer", + manufacturers={ + "IKEA of Sweden", + }, + models={"STARKVIND Air purifier"}, +) +class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): + """Sensor that displays device run time (in minutes).""" + + SENSOR_ATTR = "device_run_time" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _unit = TIME_MINUTES + + +@MULTI_MATCH( + channel_names="ikea_manufacturer", + manufacturers={ + "IKEA of Sweden", + }, + models={"STARKVIND Air purifier"}, +) +class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): + """Sensor that displays run time of the current filter (in minutes).""" + + SENSOR_ATTR = "filter_run_time" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _unit = TIME_MINUTES diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index fe7526586f9..3b044bb7646 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -285,3 +285,21 @@ class P1MotionTriggerIndicatorSwitch( """Representation of a ZHA motion triggering configuration entity.""" _zcl_attribute: str = "trigger_indicator" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"} +) +class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): + """ZHA BinarySensor.""" + + _zcl_attribute: str = "child_lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"} +) +class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): + """ZHA BinarySensor.""" + + _zcl_attribute: str = "disable_led" diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 423634db035..9ebc5ae1c79 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -2,10 +2,10 @@ from unittest.mock import AsyncMock, call, patch import pytest +import zhaquirks.ikea.starkvind from zigpy.exceptions import ZigbeeException -import zigpy.profiles.zha as zha -import zigpy.zcl.clusters.general as general -import zigpy.zcl.clusters.hvac as hvac +from zigpy.profiles import zha +from zigpy.zcl.clusters import general, hvac import zigpy.zcl.foundation as zcl_f from homeassistant.components.fan import ( @@ -57,11 +57,15 @@ def fan_platform_only(): with patch( "homeassistant.components.zha.PLATFORMS", ( + Platform.BUTTON, + Platform.BINARY_SENSOR, Platform.FAN, Platform.LIGHT, Platform.DEVICE_TRACKER, Platform.NUMBER, + Platform.SENSOR, Platform.SELECT, + Platform.SWITCH, ), ): yield @@ -516,3 +520,179 @@ async def test_fan_update_entity( assert cluster.read_attributes.await_count == 4 else: assert cluster.read_attributes.await_count == 6 + + +@pytest.fixture +def zigpy_device_ikea(zigpy_device_mock): + """Device tracker 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(hass, zha_device_joined_restored, zigpy_device_ikea): + """Test zha fan Ikea platform.""" + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + entity_id = await 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, {6: 1}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn off at fan + await send_attributes_report(hass, cluster, {6: 0}) + 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 len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 1}) + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(hass, entity_id) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 0}) + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(hass, entity_id, percentage=100) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 10}) + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 1}) + + # 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="invalid does not exist" + ) + assert len(cluster.write_attributes.mock_calls) == 0 + + # test adding new fan to the network and HA + await async_test_rejoin(hass, zigpy_device_ikea, [cluster], (9,)) + + +@pytest.mark.parametrize( + "ikea_plug_read, ikea_expected_state, ikea_expected_percentage, ikea_preset_mode", + ( + (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"), + ), +) +async def test_fan_ikea_init( + hass, + zha_device_joined_restored, + zigpy_device_ikea, + ikea_plug_read, + ikea_expected_state, + ikea_expected_percentage, + ikea_preset_mode, +): + """Test zha fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = ikea_plug_read + + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == ikea_expected_state + assert ( + hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] + == ikea_expected_percentage + ) + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == ikea_preset_mode + + +async def test_fan_ikea_update_entity( + hass, + zha_device_joined_restored, + zigpy_device_ikea, +): + """Test zha fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + entity_id = await 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 / 10 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 3 + else: + assert cluster.read_attributes.await_count == 6 + + 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 == 4 + else: + assert cluster.read_attributes.await_count == 7 + + 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] == 10 + 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 + else: + assert cluster.read_attributes.await_count == 8