Add switchbot air purifier support (#144552)

* add support for air purifier

* add unit tests for air purifier

* fix aqi translation

* fix aqi translation

* add air purifier table

* fix air purifier

* remove init and add options for aqi level
This commit is contained in:
Retha Runolfsson 2025-05-26 21:06:33 +08:00 committed by GitHub
parent 49cf66269c
commit 2d5867cab6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 374 additions and 4 deletions

View File

@ -90,6 +90,8 @@ PLATFORMS_BY_TYPE = {
Platform.LOCK,
Platform.SENSOR,
],
SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@ -113,6 +115,8 @@ CLASS_BY_DEVICE = {
SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.LOCK_LITE.value: switchbot.SwitchbotLock,
SupportedModels.LOCK_ULTRA.value: switchbot.SwitchbotLock,
SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier,
SupportedModels.AIR_PURIFIER_TABLE.value: switchbot.SwitchbotAirPurifier,
}

View File

@ -46,6 +46,8 @@ class SupportedModels(StrEnum):
HUB3 = "hub3"
LOCK_LITE = "lock_lite"
LOCK_ULTRA = "lock_ultra"
AIR_PURIFIER = "air_purifier"
AIR_PURIFIER_TABLE = "air_purifier_table"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@ -71,6 +73,8 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM,
SwitchbotModel.LOCK_LITE: SupportedModels.LOCK_LITE,
SwitchbotModel.LOCK_ULTRA: SupportedModels.LOCK_ULTRA,
SwitchbotModel.AIR_PURIFIER: SupportedModels.AIR_PURIFIER,
SwitchbotModel.AIR_PURIFIER_TABLE: SupportedModels.AIR_PURIFIER_TABLE,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@ -97,6 +101,8 @@ ENCRYPTED_MODELS = {
SwitchbotModel.LOCK_PRO,
SwitchbotModel.LOCK_LITE,
SwitchbotModel.LOCK_ULTRA,
SwitchbotModel.AIR_PURIFIER,
SwitchbotModel.AIR_PURIFIER_TABLE,
}
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@ -108,6 +114,8 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.RELAY_SWITCH_1: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.LOCK_LITE: switchbot.SwitchbotLock,
SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock,
SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier,
}
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {

View File

@ -6,7 +6,7 @@ import logging
from typing import Any
import switchbot
from switchbot import FanMode
from switchbot import AirPurifierMode, FanMode
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity
from .entity import SwitchbotEntity, exception_handler
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@ -27,7 +27,10 @@ async def async_setup_entry(
) -> None:
"""Set up Switchbot fan based on a config entry."""
coordinator = entry.runtime_data
async_add_entities([SwitchBotFanEntity(coordinator)])
if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier):
async_add_entities([SwitchBotAirPurifierEntity(coordinator)])
else:
async_add_entities([SwitchBotFanEntity(coordinator)])
class SwitchBotFanEntity(SwitchbotEntity, FanEntity, RestoreEntity):
@ -120,3 +123,65 @@ class SwitchBotFanEntity(SwitchbotEntity, FanEntity, RestoreEntity):
_LOGGER.debug("Switchbot fan to set turn off %s", self._address)
self._last_run_success = bool(await self._device.turn_off())
self.async_write_ha_state()
class SwitchBotAirPurifierEntity(SwitchbotEntity, FanEntity):
"""Representation of a Switchbot air purifier."""
_device: switchbot.SwitchbotAirPurifier
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_attr_preset_modes = AirPurifierMode.get_modes()
_attr_translation_key = "air_purifier"
_attr_name = None
@property
def is_on(self) -> bool | None:
"""Return true if device is on."""
return self._device.is_on()
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self._device.get_current_mode()
@exception_handler
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the air purifier."""
_LOGGER.debug(
"Switchbot air purifier to set preset mode %s %s",
preset_mode,
self._address,
)
self._last_run_success = bool(await self._device.set_preset_mode(preset_mode))
self.async_write_ha_state()
@exception_handler
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the air purifier."""
_LOGGER.debug(
"Switchbot air purifier to set turn on %s %s %s",
percentage,
preset_mode,
self._address,
)
self._last_run_success = bool(await self._device.turn_on())
self.async_write_ha_state()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the air purifier."""
_LOGGER.debug("Switchbot air purifier to set turn off %s", self._address)
self._last_run_success = bool(await self._device.turn_off())
self.async_write_ha_state()

View File

@ -12,6 +12,24 @@
}
}
}
},
"air_purifier": {
"default": "mdi:air-purifier",
"state": {
"off": "mdi:air-purifier-off"
},
"state_attributes": {
"preset_mode": {
"state": {
"level_1": "mdi:fan-speed-1",
"level_2": "mdi:fan-speed-2",
"level_3": "mdi:fan-speed-3",
"auto": "mdi:auto-mode",
"pet": "mdi:paw",
"sleep": "mdi:power-sleep"
}
}
}
}
}
}

View File

@ -2,6 +2,8 @@
from __future__ import annotations
from switchbot.const.air_purifier import AirQualityLevel
from homeassistant.components.bluetooth import async_last_service_info
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -102,6 +104,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
),
"aqi_level": SensorEntityDescription(
key="aqi_level",
translation_key="aqi_quality_level",
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in AirQualityLevel],
),
}

View File

@ -105,6 +105,15 @@
},
"light_level": {
"name": "Light level"
},
"aqi_quality_level": {
"name": "Air quality level",
"state": {
"excellent": "Excellent",
"good": "Good",
"moderate": "Moderate",
"unhealthy": "Unhealthy"
}
}
},
"cover": {
@ -179,6 +188,26 @@
}
}
}
},
"air_purifier": {
"state_attributes": {
"last_run_success": {
"state": {
"true": "[%key:component::binary_sensor::entity_component::problem::state::off%]",
"false": "[%key:component::binary_sensor::entity_component::problem::state::on%]"
}
},
"preset_mode": {
"state": {
"level_1": "Level 1",
"level_2": "Level 2",
"level_3": "Level 3",
"auto": "[%key:common::state::auto%]",
"pet": "Pet",
"sleep": "Sleep"
}
}
}
}
},
"vacuum": {

View File

@ -759,3 +759,103 @@ LOCK_ULTRA_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True,
tx_power=-127,
)
AIR_PURIFIER_TBALE_PM25_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Air Purifier Table PM25",
manufacturer_data={
2409: b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"7\x00\x00\x95-\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Air Purifier Table PM25",
manufacturer_data={
2409: b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"7\x00\x00\x95-\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier Table PM25"),
time=0,
connectable=True,
tx_power=-127,
)
AIR_PURIFIER_PM25_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Air Purifier PM25",
manufacturer_data={
2409: b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00',
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"*\x00\x00\x15\x04\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Air Purifier PM25",
manufacturer_data={
2409: b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00',
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"*\x00\x00\x15\x04\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier PM25"),
time=0,
connectable=True,
tx_power=-127,
)
AIR_PURIFIER_VOC_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Air Purifier VOC",
manufacturer_data={
2409: b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"+\x00\x00\x15\x04\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Air Purifier VOC",
manufacturer_data={
2409: b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"+\x00\x00\x15\x04\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier VOC"),
time=0,
connectable=True,
tx_power=-127,
)
AIR_PURIFIER_TABLE_VOC_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Air Purifier Table VOC",
manufacturer_data={
2409: b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"8\x00\x00\x95-\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Air Purifier Table VOC",
manufacturer_data={
2409: b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"8\x00\x00\x95-\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier Table VOC"),
time=0,
connectable=True,
tx_power=-127,
)

View File

@ -4,7 +4,9 @@ from collections.abc import Callable
from unittest.mock import AsyncMock, patch
import pytest
from switchbot.devices.device import SwitchbotOperationError
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.components.fan import (
ATTR_OSCILLATING,
ATTR_PERCENTAGE,
@ -16,8 +18,15 @@ from homeassistant.components.fan import (
)
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import CIRCULATOR_FAN_SERVICE_INFO
from . import (
AIR_PURIFIER_PM25_SERVICE_INFO,
AIR_PURIFIER_TABLE_VOC_SERVICE_INFO,
AIR_PURIFIER_TBALE_PM25_SERVICE_INFO,
AIR_PURIFIER_VOC_SERVICE_INFO,
CIRCULATOR_FAN_SERVICE_INFO,
)
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
@ -89,3 +98,132 @@ async def test_circulator_fan_controlling(
)
mocked_instance.assert_awaited_once()
@pytest.mark.parametrize(
("service_info", "sensor_type"),
[
(AIR_PURIFIER_VOC_SERVICE_INFO, "air_purifier"),
(AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, "air_purifier_table"),
(AIR_PURIFIER_PM25_SERVICE_INFO, "air_purifier"),
(AIR_PURIFIER_TBALE_PM25_SERVICE_INFO, "air_purifier_table"),
],
)
@pytest.mark.parametrize(
("service", "service_data", "mock_method"),
[
(
SERVICE_SET_PRESET_MODE,
{ATTR_PRESET_MODE: "sleep"},
"set_preset_mode",
),
(
SERVICE_TURN_OFF,
{},
"turn_off",
),
(
SERVICE_TURN_ON,
{},
"turn_on",
),
],
)
async def test_air_purifier_controlling(
hass: HomeAssistant,
mock_entry_encrypted_factory: Callable[[str], MockConfigEntry],
service_info: BluetoothServiceInfoBleak,
sensor_type: str,
service: str,
service_data: dict,
mock_method: str,
) -> None:
"""Test controlling the air purifier with different services."""
inject_bluetooth_service_info(hass, service_info)
entry = mock_entry_encrypted_factory(sensor_type)
entity_id = "fan.test_name"
entry.add_to_hass(hass)
mocked_instance = AsyncMock(return_value=True)
mcoked_none_instance = AsyncMock(return_value=None)
with patch.multiple(
"homeassistant.components.switchbot.fan.switchbot.SwitchbotAirPurifier",
get_basic_info=mcoked_none_instance,
update=mcoked_none_instance,
**{mock_method: mocked_instance},
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
FAN_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mocked_instance.assert_awaited_once()
@pytest.mark.parametrize(
("service_info", "sensor_type"),
[
(AIR_PURIFIER_VOC_SERVICE_INFO, "air_purifier"),
(AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, "air_purifier_table"),
(AIR_PURIFIER_PM25_SERVICE_INFO, "air_purifier"),
(AIR_PURIFIER_TBALE_PM25_SERVICE_INFO, "air_purifier_table"),
],
)
@pytest.mark.parametrize(
("service", "service_data", "mock_method"),
[
(SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "sleep"}, "set_preset_mode"),
(SERVICE_TURN_OFF, {}, "turn_off"),
(SERVICE_TURN_ON, {}, "turn_on"),
],
)
@pytest.mark.parametrize(
("exception", "error_message"),
[
(
SwitchbotOperationError("Operation failed"),
"An error occurred while performing the action: Operation failed",
),
],
)
async def test_exception_handling_air_purifier_service(
hass: HomeAssistant,
mock_entry_encrypted_factory: Callable[[str], MockConfigEntry],
service_info: BluetoothServiceInfoBleak,
sensor_type: str,
service: str,
service_data: dict,
mock_method: str,
exception: Exception,
error_message: str,
) -> None:
"""Test exception handling for air purifier service with exception."""
inject_bluetooth_service_info(hass, service_info)
entry = mock_entry_encrypted_factory(sensor_type)
entry.add_to_hass(hass)
entity_id = "fan.test_name"
mcoked_none_instance = AsyncMock(return_value=None)
with patch.multiple(
"homeassistant.components.switchbot.fan.switchbot.SwitchbotAirPurifier",
get_basic_info=mcoked_none_instance,
update=mcoked_none_instance,
**{mock_method: AsyncMock(side_effect=exception)},
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError, match=error_message):
await hass.services.async_call(
FAN_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)