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 <david.mulcahey@me.com>
This commit is contained in:
Arne Mauer 2022-06-29 17:44:40 +02:00 committed by GitHub
parent 73bff4dee5
commit fa678d0408
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 416 additions and 4 deletions

View file

@ -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

View file

@ -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
)

View file

@ -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

View file

@ -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)

View file

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

View file

@ -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

View file

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

View file

@ -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