diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py
index 8130e7d5f98ff8d3e33ecd402100e80548077012..709515d7ca24dd825b62d584923fe173df370de6 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 101db65a66ebcd159d660f8389fd97de96d702ca..943d13a57d6d2a04dd87ae2f53cc48299f38f0be 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 d271f2ecba362042c6685bbb2234a308cd00b209..2480cf1cd43a201100b262b918c71bbe2ca251db 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 8e24427b679b72e30d35e008d6324e9d8fb67da5..d947fca10abb7979ab14189192b77cd2b3bc0ec8 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 9103dd2e36459ca141864fd50714f0bd073d5908..c3d7f35231812aa82a9563894daecf5975da4fe3 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 e66f1569b817b671ae91fcb1af9f23d720eeaca9..2fe38193ecb520173d90e9862f83b1aa3a1f9950 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 fe7526586f9bab78e656f466157241c39bf479e5..3b044bb76462ab45d8827e79d4fa1d47799abfc6 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 423634db035452143b35842599fccc7d3f40f280..9ebc5ae1c793773087af89c1826a8cb408950156 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