diff --git a/tests/test_ikea.py b/tests/test_ikea.py index 8036640aa5..3f6a6e34b0 100644 --- a/tests/test_ikea.py +++ b/tests/test_ikea.py @@ -5,6 +5,7 @@ import pytest from zigpy.zcl import ClusterType, foundation from zigpy.zcl.clusters.general import Basic, LevelControl, PowerConfiguration +from zigpy.zcl.clusters.hvac import Fan from zigpy.zcl.clusters.measurement import PM25 from tests.common import ClusterListener @@ -16,75 +17,36 @@ zhaquirks.setup() -def test_ikea_starkvind(assert_signature_matches_quirk): - """Test new 'STARKVIND Air purifier table' signature is matched to its quirk.""" - - signature = { - "node_descriptor": "NodeDescriptor(logical_type=, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=, mac_capability_flags=, manufacturer_code=4476, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)", - "endpoints": { - "1": { - "profile_id": 260, - "device_type": "0x0007", - "in_clusters": [ - "0x0000", - "0x0003", - "0x0004", - "0x0005", - "0x0202", - "0xfc57", - "0xfc7d", - ], - "out_clusters": ["0x0019", "0x0400", "0x042a"], - }, - "242": { - "profile_id": 41440, - "device_type": "0x0061", - "in_clusters": [], - "out_clusters": ["0x0021"], - }, +@pytest.fixture +def starkvind_device(zigpy_device_from_v2_quirk): + """Return a quirked STARKVIND air purifier.""" + return zigpy_device_from_v2_quirk( + IKEA, + "STARKVIND Air purifier", + cluster_ids={ + 1: { + Fan.cluster_id: ClusterType.Server, + IkeaAirpurifier.cluster_id: ClusterType.Server, + PM25.cluster_id: ClusterType.Client, + } }, - "manufacturer": "IKEA of Sweden", - "model": "STARKVIND Air purifier", - "class": "ikea.starkvind.IkeaSTARKVIND", - } + ) - assert_signature_matches_quirk(zhaquirks.ikea.starkvind.IkeaSTARKVIND, signature) - - -def test_ikea_starkvind_v2(assert_signature_matches_quirk): - """Test new 'STARKVIND Air purifier table' signature is matched to its quirk.""" - - signature = { - "node_descriptor": "NodeDescriptor(logical_type=, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=, mac_capability_flags=, manufacturer_code=4476, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)", - "endpoints": { - "1": { - "profile_id": 260, - "device_type": "0x0007", - "in_clusters": [ - "0x0000", - "0x0003", - "0x0004", - "0x0005", - "0x0202", - "0xfc57", - "0xfc7c", - "0xfc7d", - ], - "out_clusters": ["0x0019", "0x0400", "0x042a"], - }, - "242": { - "profile_id": 41440, - "device_type": "0x0061", - "in_clusters": [], - "out_clusters": ["0x0021"], - }, - }, - "manufacturer": "IKEA of Sweden", - "model": "STARKVIND Air purifier table", - "class": "ikea.starkvind.IkeaSTARKVIND_v2", - } - assert_signature_matches_quirk(zhaquirks.ikea.starkvind.IkeaSTARKVIND_v2, signature) +def test_starkvind_replaced_clusters(starkvind_device): + """Test the unimplemented Fan cluster is removed and no PM25 cluster is added.""" + + endpoint = starkvind_device.endpoints[1] + assert isinstance(endpoint.in_clusters[IkeaAirpurifier.cluster_id], IkeaAirpurifier) + + # the device's `Fan` cluster is not implemented, so it must not create an entity + assert Fan.cluster_id not in endpoint.in_clusters + + # PM2.5 is read from the manufacturer specific cluster, so no virtual server + # side `PM25` cluster is added anymore. The device's own client side cluster + # is left alone, as it never created an entity to begin with. + assert PM25.cluster_id not in endpoint.in_clusters + assert PM25.cluster_id in endpoint.out_clusters @pytest.mark.parametrize("attribute", ["fan_speed", "fan_mode"]) @@ -98,17 +60,10 @@ def test_ikea_starkvind_v2(assert_signature_matches_quirk): (50, 10), ], ) -async def test_fan_speed_mode_update( - zigpy_device_from_quirk, attribute, value, expected -): +def test_fan_speed_mode_update(starkvind_device, attribute, value, expected): """Test reading the fan speed and mode.""" - starkvind_device = zigpy_device_from_quirk(zhaquirks.ikea.starkvind.IkeaSTARKVIND) - assert starkvind_device.model == "STARKVIND Air purifier" - - ikea_cluster = starkvind_device.endpoints[1].in_clusters[ - zhaquirks.ikea.starkvind.IkeaAirpurifier.cluster_id - ] + ikea_cluster = starkvind_device.endpoints[1].in_clusters[IkeaAirpurifier.cluster_id] ikea_listener = ClusterListener(ikea_cluster) attr_id = getattr(IkeaAirpurifier.AttributeDefs, attribute).id @@ -118,46 +73,38 @@ async def test_fan_speed_mode_update( assert ikea_listener.attribute_updates[0] == (attr_id, expected) -async def test_pm25_cluster_read(zigpy_device_from_quirk): - """Test reading from PM25 cluster.""" - - starkvind_device = zigpy_device_from_quirk(zhaquirks.ikea.starkvind.IkeaSTARKVIND) - assert starkvind_device.model == "STARKVIND Air purifier" +@pytest.mark.parametrize( + "value,expected", + [ + (0, 0), # off + (1, 1), # auto + (2, 10), + (4, 20), + (10, 50), + (11, 11), # out of range, written as-is + ], +) +async def test_fan_mode_write(starkvind_device, value, expected): + """Test writing the fan mode scales it back up to the device's range.""" - pm25_cluster = starkvind_device.endpoints[1].in_clusters[PM25.cluster_id] - ikea_cluster = starkvind_device.endpoints[1].in_clusters[ - zhaquirks.ikea.starkvind.IkeaAirpurifier.cluster_id - ] + ikea_cluster = starkvind_device.endpoints[1].in_clusters[IkeaAirpurifier.cluster_id] - # Mock the read attribute to on the IkeaAirpurifier cluster - # to always return 6 for anything. - def mock_read(attributes, manufacturer=None): - records = [ - foundation.ReadAttributeRecord( - attr, foundation.Status.SUCCESS, foundation.TypeValue(None, 6) - ) - for attr in attributes + write_mock = mock.AsyncMock( + return_value=[ + [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)] ] - return (records,) - - patch_ikeacluster_read = mock.patch.object( - ikea_cluster, "_read_attributes", mock.AsyncMock(side_effect=mock_read) ) - with patch_ikeacluster_read: - # Reading "measured_value" should read the "air_quality_25pm" value from - # the IkeaAirpurifier cluster - success, fail = await pm25_cluster.read_attributes(["measured_value"]) - assert success - assert 6 in success.values() - assert not fail - - # Same call with allow_cache=True; a bug previously prevented this from working - success, fail = await pm25_cluster.read_attributes( - ["measured_value"], allow_cache=True - ) - assert success - assert 6 in success.values() - assert not fail + with mock.patch.object(ikea_cluster, "_write_attributes", write_mock): + # other attributes written in the same call are passed through untouched + await ikea_cluster.write_attributes({"fan_mode": value, "child_lock": 1}) + + written = { + attr.attrid: attr.value.value for attr in write_mock.mock_calls[0].args[0] + } + assert written == { + IkeaAirpurifier.AttributeDefs.fan_mode.id: expected, + IkeaAirpurifier.AttributeDefs.child_lock.id: 1, + } @mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) diff --git a/tests/test_quirks.py b/tests/test_quirks.py index 36f68f1ee3..30cc2f2e8d 100644 --- a/tests/test_quirks.py +++ b/tests/test_quirks.py @@ -903,9 +903,6 @@ def check_for_duplicate_cluster_ids(clusters) -> None: zhaquirks.xiaomi.aqara.vibration_aq1.VibrationAQ1, # # -- IKEA devices -- - # swap PM25 cluster from output to input cluster (IKEA Starkvind): - zhaquirks.ikea.starkvind.IkeaSTARKVIND, - zhaquirks.ikea.starkvind.IkeaSTARKVIND_v2, # removes Group input cluster (IKEA remote): zhaquirks.ikea.twobtnremote.IkeaRodretRemote2BtnNew, zhaquirks.ikea.somrigsmartbtn.IkeaSomrigSmartButton, diff --git a/zhaquirks/ikea/starkvind.py b/zhaquirks/ikea/starkvind.py index a9d2b4de2c..7dfd2e1043 100644 --- a/zhaquirks/ikea/starkvind.py +++ b/zhaquirks/ikea/starkvind.py @@ -2,35 +2,43 @@ from __future__ import annotations -from typing import Any +from typing import Any, Final -from zigpy.profiles import zgp, zha import zigpy.types as t from zigpy.zcl import foundation -from zigpy.zcl.clusters.general import ( - Basic, - GreenPowerProxy, - Groups, - Identify, - Ota, - Scenes, -) from zigpy.zcl.clusters.hvac import Fan -from zigpy.zcl.clusters.measurement import PM25, IlluminanceMeasurement +from zigpy.zcl.clusters.measurement import PM25 from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef -from zhaquirks import Bus +from zhaquirks.builder import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + BinarySensorDeviceClass, + EntityType, + QuirkBuilder, + ReportingConfig, + SensorDeviceClass, + SensorStateClass, + UnitOfTime, +) from zhaquirks.clusters import CustomCluster -from zhaquirks.const import ( - DEVICE_TYPE, - ENDPOINTS, - INPUT_CLUSTERS, - MODELS_INFO, - OUTPUT_CLUSTERS, - PROFILE_ID, +from zhaquirks.ikea import IKEA + +# The device reports fan mode and fan speed in steps of five (10-50), which are +# scaled down to the 2-10 range ZHA's fan entity expects. 0 is off and 1 is auto. +FAN_STEP: Final = 5 +FAN_RAW_MIN: Final = 10 +FAN_RAW_MAX: Final = 50 +FAN_MIN: Final = FAN_RAW_MIN // FAN_STEP # 2 +FAN_MAX: Final = FAN_RAW_MAX // FAN_STEP # 10 + +# Report as soon as the value changes, and at least every 15 minutes. +REPORT_ON_CHANGE: Final = ReportingConfig( + min_interval=0, max_interval=900, reportable_change=1 +) +# Same, but for the counters that tick every minute: at most one report per 30s. +REPORT_THROTTLED: Final = ReportingConfig( + min_interval=30, max_interval=900, reportable_change=1 ) -from zhaquirks.ikea import IKEA, IKEA_CLUSTER_ID, WWAH_CLUSTER_ID -from zhaquirks.legacy import CustomDevice class IkeaAirpurifier(CustomCluster): @@ -43,45 +51,43 @@ class IkeaAirpurifier(CustomCluster): class AttributeDefs(BaseAttributeDefs): """Cluster attributes.""" - filter_run_time = ZCLAttributeDef( + filter_run_time: Final = ZCLAttributeDef( id=0x0000, type=t.uint32_t, manufacturer_code=0x117C ) - replace_filter = ZCLAttributeDef( + replace_filter: Final = ZCLAttributeDef( id=0x0001, type=t.uint8_t, manufacturer_code=0x117C ) - filter_life_time = ZCLAttributeDef( + filter_life_time: Final = ZCLAttributeDef( id=0x0002, type=t.uint32_t, manufacturer_code=0x117C ) - disable_led = ZCLAttributeDef(id=0x0003, type=t.Bool, manufacturer_code=0x117C) - air_quality_25pm = ZCLAttributeDef( + disable_led: Final = ZCLAttributeDef( + id=0x0003, type=t.Bool, manufacturer_code=0x117C + ) + # PM2.5 in µg/m³. 0xFFFF means the value is unavailable (device off), + # which ZHA already surfaces as "unknown" for a uint16 attribute. + air_quality_25pm: Final = ZCLAttributeDef( id=0x0004, type=t.uint16_t, manufacturer_code=0x117C ) - child_lock = ZCLAttributeDef(id=0x0005, type=t.Bool, manufacturer_code=0x117C) - fan_mode = ZCLAttributeDef( + child_lock: Final = ZCLAttributeDef( + id=0x0005, type=t.Bool, manufacturer_code=0x117C + ) + fan_mode: Final = ZCLAttributeDef( id=0x0006, type=t.uint8_t, manufacturer_code=0x117C ) # fan mode (Off, Auto, fanspeed 10 - 50) read/write - fan_speed = ZCLAttributeDef( + fan_speed: Final = ZCLAttributeDef( id=0x0007, type=t.uint8_t, manufacturer_code=0x117C ) # current fan speed (only fan speed 10-50) - device_run_time = ZCLAttributeDef( + device_run_time: Final = ZCLAttributeDef( id=0x0008, type=t.uint32_t, manufacturer_code=0x117C ) - def __init__(self, *args, **kwargs): - """Init.""" - self._current_state = {} - super().__init__(*args, **kwargs) - self.endpoint.device.change_fan_mode_bus.add_listener(self) - - def _update_attribute(self, attrid, value): - if attrid == 0x0004: - if ( - value is not None and value < 5500 - ): # > 5500 = out of scale; if value is 65535 (0xFFFF), device is off - self.endpoint.device.pm25_bus.listener_event("update_state", value) - elif attrid in (0x0006, 0x0007): - if value >= 10 and value <= 50: - value = value // 5 + def _update_attribute(self, attrid: int, value: Any) -> None: + """Scale the reported fan mode and fan speed down to 2-10.""" + if ( + attrid in (self.AttributeDefs.fan_mode.id, self.AttributeDefs.fan_speed.id) + and FAN_RAW_MIN <= value <= FAN_RAW_MAX + ): + value = value // FAN_STEP super()._update_attribute(attrid, value) async def write_attributes( @@ -89,208 +95,92 @@ async def write_attributes( attributes: dict[str | int | foundation.ZCLAttributeDef, Any], **kwargs, ) -> list[list[foundation.WriteAttributesStatusRecord]]: - """Override wrong writes to thermostat attributes.""" - if "fan_mode" in attributes: - fan_mode = attributes.get("fan_mode") - if fan_mode and fan_mode > 1 and fan_mode < 11: - fan_mode = fan_mode * 5 - return await super().write_attributes({"fan_mode": fan_mode}, **kwargs) + """Scale a written fan mode back up to the device's 10-50 range.""" + fan_mode_name = self.AttributeDefs.fan_mode.name + fan_mode = attributes.get(fan_mode_name) + if fan_mode is not None and FAN_MIN <= fan_mode <= FAN_MAX: + attributes = {**attributes, fan_mode_name: fan_mode * FAN_STEP} return await super().write_attributes(attributes, **kwargs) -class PM25Cluster(CustomCluster, PM25): - """PM25 input cluster, only used to show PM2.5 values from IKEA cluster.""" - - def __init__(self, *args, **kwargs): - """Init.""" - super().__init__(*args, **kwargs) - self.endpoint.device.pm25_bus.add_listener(self) - - def update_state(self, value): - """25pm reported.""" - self._update_attribute(0x0000, value) - - def _update_attribute(self, attrid, value): - """Check for a valid PM2.5 value.""" - if attrid == 0x0000: - if value < 5500: - super()._update_attribute(attrid, value) - else: - super()._update_attribute(attrid, value) - - async def read_attributes( - self, - attributes: list[int | str | foundation.ZCLAttributeDef], - **kwargs, - ) -> Any: - """Read attributes ZCL foundation command.""" - if "measured_value" in attributes: - return ( - await self.endpoint.device.endpoints[1] - .in_clusters[64637] - .read_attributes(["air_quality_25pm"], **kwargs) - ) - else: - return await super().read_attributes(attributes, **kwargs) - - -class IkeaSTARKVIND(CustomDevice): - """STARKVIND Air purifier by IKEA of Sweden.""" - - def __init__(self, *args, **kwargs): - """Init.""" - self.pm25_bus = Bus() - self.change_fan_mode_bus = Bus() - self.change_fan_mode_ha_bus = Bus() - super().__init__(*args, **kwargs) - - signature = { - # - MODELS_INFO: [ - (IKEA, "STARKVIND Air purifier"), - (IKEA, "STARKVIND Air purifier table"), - ], - ENDPOINTS: { - 1: { - PROFILE_ID: zha.PROFILE_ID, - DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE, - INPUT_CLUSTERS: [ - Basic.cluster_id, # 0 - Identify.cluster_id, # 3 - Groups.cluster_id, # 4 - Scenes.cluster_id, # 5 - Fan.cluster_id, # 514 0x0202 - WWAH_CLUSTER_ID, # 64599 0xFC57 - IkeaAirpurifier.cluster_id, # 64637 0xFC7D - ], - OUTPUT_CLUSTERS: [ - Ota.cluster_id, # 25 0x0019 - IlluminanceMeasurement.cluster_id, # 1024 0x0400 - PM25.cluster_id, # 1066 0x042A PM2.5 Measurement Cluster - ], - }, - # - 242: { - PROFILE_ID: zgp.PROFILE_ID, # 41440 (dec) - DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, - INPUT_CLUSTERS: [], - OUTPUT_CLUSTERS: [ - GreenPowerProxy.cluster_id, # 0x0021 = GreenPowerProxy.cluster_id - ], - }, - }, - } - - replacement = { - ENDPOINTS: { - 1: { - PROFILE_ID: zha.PROFILE_ID, - DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE, - INPUT_CLUSTERS: [ - Basic.cluster_id, # 0 - Identify.cluster_id, # 3 - Groups.cluster_id, # 4 - Scenes.cluster_id, # 5 - WWAH_CLUSTER_ID, # 64599 0xFC57 - IkeaAirpurifier, # 64637 0xFC7D control air purifier with manufacturer-specific attributes - PM25Cluster, # 1066 0x042A PM2.5 Measurement Cluster - ], - OUTPUT_CLUSTERS: [ - Ota.cluster_id, # 25 0x0019 - IlluminanceMeasurement.cluster_id, # 1024 0x0400 - ], - }, - # - 242: { - PROFILE_ID: zgp.PROFILE_ID, # 41440 (dec) - DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, - INPUT_CLUSTERS: [], - OUTPUT_CLUSTERS: [ - GreenPowerProxy.cluster_id, # 0x0021 = GreenPowerProxy.cluster_id - ], - }, - }, - } - - -class IkeaSTARKVIND_v2(IkeaSTARKVIND): - """STARKVIND Air purifier by IKEA of Sweden.""" - - signature = { - # - MODELS_INFO: IkeaSTARKVIND.signature[MODELS_INFO].copy(), - ENDPOINTS: { - 1: { - PROFILE_ID: zha.PROFILE_ID, - DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE, - INPUT_CLUSTERS: [ - Basic.cluster_id, # 0 - Identify.cluster_id, # 3 - Groups.cluster_id, # 4 - Scenes.cluster_id, # 5 - Fan.cluster_id, # 514 0x0202 - WWAH_CLUSTER_ID, # 64599 0xFC57 - IKEA_CLUSTER_ID, # 64636 0xFC7C - IkeaAirpurifier.cluster_id, # 64637 0xFC7D - ], - OUTPUT_CLUSTERS: [ - Ota.cluster_id, # 25 0x0019 - IlluminanceMeasurement.cluster_id, # 1024 0x0400 - PM25.cluster_id, # 1066 0x042A PM2.5 Measurement Cluster - ], - }, - # - 242: { - PROFILE_ID: zgp.PROFILE_ID, # 41440 (dec) - DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, - INPUT_CLUSTERS: [], - OUTPUT_CLUSTERS: [ - GreenPowerProxy.cluster_id, # 0x0021 = GreenPowerProxy.cluster_id - ], - }, - }, - } - - replacement = { - ENDPOINTS: { - 1: { - PROFILE_ID: zha.PROFILE_ID, - DEVICE_TYPE: zha.DeviceType.COMBINED_INTERFACE, - INPUT_CLUSTERS: [ - Basic.cluster_id, # 0 - Identify.cluster_id, # 3 - Groups.cluster_id, # 4 - Scenes.cluster_id, # 5 - WWAH_CLUSTER_ID, # 64599 0xFC57 - IKEA_CLUSTER_ID, # 64636 0xFC7C - IkeaAirpurifier, # 64637 0xFC7D control air purifier with manufacturer-specific attributes - PM25Cluster, # 1066 0x042A PM2.5 Measurement Cluster - ], - OUTPUT_CLUSTERS: [ - Ota.cluster_id, # 25 0x0019 - IlluminanceMeasurement.cluster_id, # 1024 0x0400 - ], - }, - # - 242: { - PROFILE_ID: zgp.PROFILE_ID, # 41440 (dec) - DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, - INPUT_CLUSTERS: [], - OUTPUT_CLUSTERS: [ - GreenPowerProxy.cluster_id, # 0x0021 = GreenPowerProxy.cluster_id - ], - }, - }, - } +( + QuirkBuilder(IKEA, "STARKVIND Air purifier") + .applies_to(IKEA, "STARKVIND Air purifier table") + # The device exposes a standard `Fan` cluster, but it is not implemented. + # Fan control happens through the manufacturer specific cluster below. + .removes(Fan.cluster_id) + .replaces(IkeaAirpurifier) + # PM2.5 is only reported on the manufacturer specific cluster. This quirk used + # to mirror it onto a virtual `PM25` cluster, which made ZHA bind and configure + # reporting for an attribute the device does not implement. The unique_id of + # that sensor is preserved here so the entity survives the change. + .sensor( + attribute_name=IkeaAirpurifier.AttributeDefs.air_quality_25pm.name, + cluster_id=IkeaAirpurifier.cluster_id, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + reporting_config=REPORT_ON_CHANGE, + unique_id_suffix=str(PM25.cluster_id), + fallback_name="PM2.5", + ) + .binary_sensor( + attribute_name=IkeaAirpurifier.AttributeDefs.replace_filter.name, + cluster_id=IkeaAirpurifier.cluster_id, + device_class=BinarySensorDeviceClass.PROBLEM, + reporting_config=REPORT_ON_CHANGE, + unique_id_suffix=f"{IkeaAirpurifier.cluster_id}-replace_filter", + translation_key="replace_filter", + fallback_name="Replace filter", + ) + .sensor( + attribute_name=IkeaAirpurifier.AttributeDefs.filter_run_time.name, + cluster_id=IkeaAirpurifier.cluster_id, + entity_type=EntityType.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + unit=UnitOfTime.MINUTES, + reporting_config=REPORT_THROTTLED, + unique_id_suffix=f"{IkeaAirpurifier.cluster_id}-filter_run_time", + translation_key="filter_run_time", + fallback_name="Filter run time", + ) + .sensor( + attribute_name=IkeaAirpurifier.AttributeDefs.device_run_time.name, + cluster_id=IkeaAirpurifier.cluster_id, + entity_type=EntityType.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + unit=UnitOfTime.MINUTES, + reporting_config=REPORT_THROTTLED, + unique_id_suffix=f"{IkeaAirpurifier.cluster_id}-device_run_time", + translation_key="device_run_time", + fallback_name="Device run time", + ) + .number( + attribute_name=IkeaAirpurifier.AttributeDefs.filter_life_time.name, + cluster_id=IkeaAirpurifier.cluster_id, + min_value=0, + max_value=0xFFFFFFFF, + unit=UnitOfTime.MINUTES, + reporting_config=REPORT_THROTTLED, + unique_id_suffix=f"{IkeaAirpurifier.cluster_id}-filter_life_time", + translation_key="filter_life_time", + fallback_name="Filter life time", + ) + .switch( + attribute_name=IkeaAirpurifier.AttributeDefs.child_lock.name, + cluster_id=IkeaAirpurifier.cluster_id, + reporting_config=REPORT_ON_CHANGE, + unique_id_suffix=f"{IkeaAirpurifier.cluster_id}-child_lock", + translation_key="child_lock", + fallback_name="Child lock", + ) + .switch( + attribute_name=IkeaAirpurifier.AttributeDefs.disable_led.name, + cluster_id=IkeaAirpurifier.cluster_id, + reporting_config=REPORT_ON_CHANGE, + unique_id_suffix=f"{IkeaAirpurifier.cluster_id}-disable_led", + translation_key="disable_led", + fallback_name="Disable LED", + ) + .add_to_registry() +)