From 2d5867cab6fcd2673b2df41ccec9987c0f5f3e44 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 26 May 2025 21:06:33 +0800 Subject: [PATCH] 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 --- .../components/switchbot/__init__.py | 4 + homeassistant/components/switchbot/const.py | 8 + homeassistant/components/switchbot/fan.py | 71 ++++++++- homeassistant/components/switchbot/icons.json | 18 +++ homeassistant/components/switchbot/sensor.py | 8 + .../components/switchbot/strings.json | 29 ++++ tests/components/switchbot/__init__.py | 100 +++++++++++++ tests/components/switchbot/test_fan.py | 140 +++++++++++++++++- 8 files changed, 374 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index ee7d0b7e658..af4001f0d9a 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -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, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index aae189be2e1..f6536ca3ff3 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -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 = { diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py index f704af309bf..9a7260f5925 100644 --- a/homeassistant/components/switchbot/fan.py +++ b/homeassistant/components/switchbot/fan.py @@ -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() diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index a1c1682d255..9dd46e0717a 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -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" + } + } + } } } } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index d68c913db15..75ac0f7bc74 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -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], + ), } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index a5f502a261b..c758ae645ae 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -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": { diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 1e90b0bf1fe..5dca8167e05 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -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, +) diff --git a/tests/components/switchbot/test_fan.py b/tests/components/switchbot/test_fan.py index 815d3aceda3..bd0306a133c 100644 --- a/tests/components/switchbot/test_fan.py +++ b/tests/components/switchbot/test_fan.py @@ -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, + )