From 89abe65e1d6cf61e3a14c481b787adc917a6cf2c Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Tue, 19 Aug 2025 22:02:18 +0800 Subject: [PATCH] Add air purifier for switchbot cloud integration (#147001) --- .../components/switchbot_cloud/__init__.py | 5 + .../components/switchbot_cloud/const.py | 16 +- .../components/switchbot_cloud/fan.py | 93 +++++++++- .../components/switchbot_cloud/icons.json | 22 +++ .../components/switchbot_cloud/strings.json | 16 ++ tests/components/switchbot_cloud/__init__.py | 19 ++ .../fixtures/air_purifier_status.json | 8 + .../switchbot_cloud/snapshots/test_fan.ambr | 64 +++++++ tests/components/switchbot_cloud/test_fan.py | 172 +++++++++++++----- 9 files changed, 361 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/switchbot_cloud/icons.json create mode 100644 tests/components/switchbot_cloud/fixtures/air_purifier_status.json create mode 100644 tests/components/switchbot_cloud/snapshots/test_fan.ambr diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 44fbfe0fcf4..edf30984fe6 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -184,6 +184,11 @@ async def make_device_data( devices_data.buttons.append((device, coordinator)) else: devices_data.switches.append((device, coordinator)) + if isinstance(device, Device) and device.device_type.startswith("Air Purifier"): + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.fans.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ "Battery Circulator Fan", diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index a9b3d0df412..23a212075c4 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -1,6 +1,7 @@ """Constants for the SwitchBot Cloud integration.""" from datetime import timedelta +from enum import Enum from typing import Final DOMAIN: Final = "switchbot_cloud" @@ -17,5 +18,18 @@ VACUUM_FAN_SPEED_STRONG = "strong" VACUUM_FAN_SPEED_MAX = "max" AFTER_COMMAND_REFRESH = 5 - COVER_ENTITY_AFTER_COMMAND_REFRESH = 10 + + +class AirPurifierMode(Enum): + """Air Purifier Modes.""" + + NORMAL = 1 + AUTO = 2 + SLEEP = 3 + PET = 4 + + @classmethod + def get_modes(cls) -> list[str]: + """Return a list of available air purifier modes as lowercase strings.""" + return [mode.name.lower() for mode in cls] diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py index 418296ffb55..9424b5478ac 100644 --- a/homeassistant/components/switchbot_cloud/fan.py +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -1,23 +1,30 @@ """Support for the Switchbot Battery Circulator fan.""" import asyncio +import logging from typing import Any from switchbot_api import ( + AirPurifierCommands, BatteryCirculatorFanCommands, BatteryCirculatorFanMode, CommonCommands, + SwitchBotAPI, ) from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData -from .const import AFTER_COMMAND_REFRESH, DOMAIN +from .const import AFTER_COMMAND_REFRESH, DOMAIN, AirPurifierMode from .entity import SwitchBotCloudEntity +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -26,10 +33,13 @@ async def async_setup_entry( ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] - async_add_entities( - SwitchBotCloudFan(data.api, device, coordinator) - for device, coordinator in data.devices.fans - ) + for device, coordinator in data.devices.fans: + if device.device_type.startswith("Air Purifier"): + async_add_entities( + [SwitchBotAirPurifierEntity(data.api, device, coordinator)] + ) + else: + async_add_entities([SwitchBotCloudFan(data.api, device, coordinator)]) class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): @@ -37,6 +47,7 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): _attr_name = None + _api: SwitchBotAPI _attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE @@ -118,3 +129,75 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): ) await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() + + +class SwitchBotAirPurifierEntity(SwitchBotCloudEntity, FanEntity): + """Representation of a Switchbot air purifier.""" + + _api: SwitchBotAPI + _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 + _attr_is_on: bool | None = None + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self._attr_is_on + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + self._attr_is_on = self.coordinator.data.get("power") == STATE_ON.upper() + mode = self.coordinator.data.get("mode") + self._attr_preset_mode = ( + AirPurifierMode(mode).name.lower() if mode is not None else None + ) + + 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._attr_unique_id, + ) + await self.send_api_command( + AirPurifierCommands.SET_MODE, + parameters={"mode": AirPurifierMode[preset_mode.upper()].value}, + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + 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._attr_unique_id, + ) + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + 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._attr_unique_id) + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json new file mode 100644 index 00000000000..2a13cbe7579 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -0,0 +1,22 @@ +{ + "entity": { + "fan": { + "air_purifier": { + "default": "mdi:air-purifier", + "state": { + "off": "mdi:air-purifier-off" + }, + "state_attributes": { + "preset_mode": { + "state": { + "normal": "mdi:fan", + "auto": "mdi:auto-mode", + "pet": "mdi:paw", + "sleep": "mdi:power-sleep" + } + } + } + } + } + } +} diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index 11e92e6dfa3..adb7de00682 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -16,5 +16,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "fan": { + "air_purifier": { + "state_attributes": { + "preset_mode": { + "state": { + "normal": "[%key:common::state::normal%]", + "auto": "[%key:common::state::auto%]", + "pet": "Pet", + "sleep": "Sleep" + } + } + } + } + } } } diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index 42fe3e4f543..b0d1c29f4a9 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -1,5 +1,7 @@ """Tests for the SwitchBot Cloud integration.""" +from switchbot_api import Device + from homeassistant.components.switchbot_cloud.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant @@ -21,3 +23,20 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: await hass.async_block_till_done() return entry + + +AIR_PURIFIER_INFO = Device( + version="V1.0", + deviceId="air-purifier-id-1", + deviceName="air-purifier-1", + deviceType="Air Purifier Table PM2.5", + hubDeviceId="test-hub-id", +) + +CIRCULATOR_FAN_INFO = Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", +) diff --git a/tests/components/switchbot_cloud/fixtures/air_purifier_status.json b/tests/components/switchbot_cloud/fixtures/air_purifier_status.json new file mode 100644 index 00000000000..b490c1c966c --- /dev/null +++ b/tests/components/switchbot_cloud/fixtures/air_purifier_status.json @@ -0,0 +1,8 @@ +{ + "version": "V2.3", + "power": "ON", + "mode": 2, + "deviceId": "air-purifier-id-1", + "deviceType": "Air Purifier Table PM2.5", + "hubDeviceId": "test-hub-id" +} diff --git a/tests/components/switchbot_cloud/snapshots/test_fan.ambr b/tests/components/switchbot_cloud/snapshots/test_fan.ambr new file mode 100644 index 00000000000..e5139527aca --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_fan.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_air_purifier[fan.air_purifier_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'normal', + 'auto', + 'sleep', + 'pet', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.air_purifier_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'air_purifier', + 'unique_id': 'air-purifier-id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_air_purifier[fan.air_purifier_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'air-purifier-1', + 'preset_mode': 'auto', + 'preset_modes': list([ + 'normal', + 'auto', + 'sleep', + 'pet', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.air_purifier_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_fan.py b/tests/components/switchbot_cloud/test_fan.py index 4a9eb527818..9852096511a 100644 --- a/tests/components/switchbot_cloud/test_fan.py +++ b/tests/components/switchbot_cloud/test_fan.py @@ -2,7 +2,10 @@ from unittest.mock import patch +import pytest +import switchbot_api from switchbot_api import Device, SwitchBotAPI +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, @@ -12,6 +15,7 @@ from homeassistant.components.fan import ( SERVICE_SET_PRESET_MODE, SERVICE_TURN_ON, ) +from homeassistant.components.switchbot_cloud.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -19,32 +23,37 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from . import configure_integration +from . import AIR_PURIFIER_INFO, CIRCULATOR_FAN_INFO, configure_integration + +from tests.common import async_load_json_object_fixture, snapshot_platform +@pytest.mark.parametrize( + ("device_info", "entry_id"), + [ + (AIR_PURIFIER_INFO, "fan.air_purifier_1"), + (CIRCULATOR_FAN_INFO, "fan.battery_fan_1"), + ], +) async def test_coordinator_data_is_none( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + device_info: Device, + entry_id: str, ) -> None: """Test coordinator data is none.""" - mock_list_devices.return_value = [ - Device( - version="V1.0", - deviceId="battery-fan-id-1", - deviceName="battery-fan-1", - deviceType="Battery Circulator Fan", - hubDeviceId="test-hub-id", - ), - ] - mock_get_status.side_effect = [ - None, - ] + mock_list_devices.return_value = [device_info] + mock_get_status.side_effect = [None] + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED - entity_id = "fan.battery_fan_1" - state = hass.states.get(entity_id) + state = hass.states.get(entry_id) assert state.state == STATE_UNKNOWN @@ -52,13 +61,7 @@ async def test_coordinator_data_is_none( async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None: """Test turning on the fan.""" mock_list_devices.return_value = [ - Device( - version="V1.0", - deviceId="battery-fan-id-1", - deviceName="battery-fan-1", - deviceType="Battery Circulator Fan", - hubDeviceId="test-hub-id", - ), + CIRCULATOR_FAN_INFO, ] mock_get_status.side_effect = [ {"power": "off", "mode": "direct", "fanSpeed": "0"}, @@ -72,7 +75,9 @@ async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) assert state.state == STATE_OFF - with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -87,13 +92,7 @@ async def test_turn_off( ) -> None: """Test turning off the fan.""" mock_list_devices.return_value = [ - Device( - version="V1.0", - deviceId="battery-fan-id-1", - deviceName="battery-fan-1", - deviceType="Battery Circulator Fan", - hubDeviceId="test-hub-id", - ), + CIRCULATOR_FAN_INFO, ] mock_get_status.side_effect = [ {"power": "on", "mode": "direct", "fanSpeed": "0"}, @@ -107,7 +106,9 @@ async def test_turn_off( assert state.state == STATE_ON - with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -122,13 +123,7 @@ async def test_set_percentage( ) -> None: """Test set percentage.""" mock_list_devices.return_value = [ - Device( - version="V1.0", - deviceId="battery-fan-id-1", - deviceName="battery-fan-1", - deviceType="Battery Circulator Fan", - hubDeviceId="test-hub-id", - ), + CIRCULATOR_FAN_INFO, ] mock_get_status.side_effect = [ {"power": "on", "mode": "direct", "fanSpeed": "0"}, @@ -142,7 +137,9 @@ async def test_set_percentage( assert state.state == STATE_ON - with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, @@ -157,13 +154,7 @@ async def test_set_preset_mode( ) -> None: """Test set preset mode.""" mock_list_devices.return_value = [ - Device( - version="V1.0", - deviceId="battery-fan-id-1", - deviceName="battery-fan-1", - deviceType="Battery Circulator Fan", - hubDeviceId="test-hub-id", - ), + CIRCULATOR_FAN_INFO, ] mock_get_status.side_effect = [ {"power": "on", "mode": "direct", "fanSpeed": "0"}, @@ -177,7 +168,9 @@ async def test_set_preset_mode( assert state.state == STATE_ON - with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -185,3 +178,86 @@ async def test_set_preset_mode( blocking=True, ) mock_send_command.assert_called_once() + + +async def test_air_purifier( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test air purifier.""" + + mock_list_devices.return_value = [AIR_PURIFIER_INFO] + mock_get_status.return_value = await async_load_json_object_fixture( + hass, "air_purifier_status.json", DOMAIN + ) + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.FAN]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_call_args"), + [ + ( + "turn_on", + {}, + ( + "air-purifier-id-1", + switchbot_api.CommonCommands.ON, + "command", + "default", + ), + ), + ( + "turn_off", + {}, + ( + "air-purifier-id-1", + switchbot_api.CommonCommands.OFF, + "command", + "default", + ), + ), + ( + "set_preset_mode", + {"preset_mode": "sleep"}, + ( + "air-purifier-id-1", + switchbot_api.AirPurifierCommands.SET_MODE, + "command", + {"mode": 3}, + ), + ), + ], +) +async def test_air_purifier_controller( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + service_data: dict, + expected_call_args: tuple, +) -> None: + """Test controlling the air purifier with mocked delay.""" + mock_list_devices.return_value = [AIR_PURIFIER_INFO] + mock_get_status.return_value = {"power": "OFF", "mode": 2} + + await configure_integration(hass) + fan_id = "fan.air_purifier_1" + + with ( + patch.object(SwitchBotAPI, "send_command") as mocked_send_command, + ): + await hass.services.async_call( + FAN_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: fan_id}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once_with(*expected_call_args)