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.LOCK,
Platform.SENSOR, Platform.SENSOR,
], ],
SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR],
} }
CLASS_BY_DEVICE = { CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@ -113,6 +115,8 @@ CLASS_BY_DEVICE = {
SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum, SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.LOCK_LITE.value: switchbot.SwitchbotLock, SupportedModels.LOCK_LITE.value: switchbot.SwitchbotLock,
SupportedModels.LOCK_ULTRA.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" HUB3 = "hub3"
LOCK_LITE = "lock_lite" LOCK_LITE = "lock_lite"
LOCK_ULTRA = "lock_ultra" LOCK_ULTRA = "lock_ultra"
AIR_PURIFIER = "air_purifier"
AIR_PURIFIER_TABLE = "air_purifier_table"
CONNECTABLE_SUPPORTED_MODEL_TYPES = { CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@ -71,6 +73,8 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM, SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM,
SwitchbotModel.LOCK_LITE: SupportedModels.LOCK_LITE, SwitchbotModel.LOCK_LITE: SupportedModels.LOCK_LITE,
SwitchbotModel.LOCK_ULTRA: SupportedModels.LOCK_ULTRA, 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 = { NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@ -97,6 +101,8 @@ ENCRYPTED_MODELS = {
SwitchbotModel.LOCK_PRO, SwitchbotModel.LOCK_PRO,
SwitchbotModel.LOCK_LITE, SwitchbotModel.LOCK_LITE,
SwitchbotModel.LOCK_ULTRA, SwitchbotModel.LOCK_ULTRA,
SwitchbotModel.AIR_PURIFIER,
SwitchbotModel.AIR_PURIFIER_TABLE,
} }
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@ -108,6 +114,8 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.RELAY_SWITCH_1: switchbot.SwitchbotRelaySwitch, SwitchbotModel.RELAY_SWITCH_1: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.LOCK_LITE: switchbot.SwitchbotLock, SwitchbotModel.LOCK_LITE: switchbot.SwitchbotLock,
SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock, SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock,
SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier,
} }
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {

View File

@ -6,7 +6,7 @@ import logging
from typing import Any from typing import Any
import switchbot import switchbot
from switchbot import FanMode from switchbot import AirPurifierMode, FanMode
from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity from .entity import SwitchbotEntity, exception_handler
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -27,7 +27,10 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Switchbot fan based on a config entry.""" """Set up Switchbot fan based on a config entry."""
coordinator = entry.runtime_data 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): 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) _LOGGER.debug("Switchbot fan to set turn off %s", self._address)
self._last_run_success = bool(await self._device.turn_off()) self._last_run_success = bool(await self._device.turn_off())
self.async_write_ha_state() 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 __future__ import annotations
from switchbot.const.air_purifier import AirQualityLevel
from homeassistant.components.bluetooth import async_last_service_info from homeassistant.components.bluetooth import async_last_service_info
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -102,6 +104,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE, 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": { "light_level": {
"name": "Light level" "name": "Light level"
},
"aqi_quality_level": {
"name": "Air quality level",
"state": {
"excellent": "Excellent",
"good": "Good",
"moderate": "Moderate",
"unhealthy": "Unhealthy"
}
} }
}, },
"cover": { "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": { "vacuum": {

View File

@ -759,3 +759,103 @@ LOCK_ULTRA_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True, connectable=True,
tx_power=-127, 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 from unittest.mock import AsyncMock, patch
import pytest import pytest
from switchbot.devices.device import SwitchbotOperationError
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.components.fan import ( from homeassistant.components.fan import (
ATTR_OSCILLATING, ATTR_OSCILLATING,
ATTR_PERCENTAGE, 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.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant 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.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info from tests.components.bluetooth import inject_bluetooth_service_info
@ -89,3 +98,132 @@ async def test_circulator_fan_controlling(
) )
mocked_instance.assert_awaited_once() 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,
)