Add air purifier for switchbot cloud integration (#147001)

This commit is contained in:
Retha Runolfsson
2025-08-19 22:02:18 +08:00
committed by GitHub
parent 785c9ebc3b
commit 89abe65e1d
9 changed files with 361 additions and 54 deletions

View File

@@ -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",

View File

@@ -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]

View File

@@ -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()

View File

@@ -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"
}
}
}
}
}
}
}

View File

@@ -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"
}
}
}
}
}
}
}

View File

@@ -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",
)

View File

@@ -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"
}

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.air_purifier_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <FanEntityFeature: 56>,
'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': <FanEntityFeature: 56>,
}),
'context': <ANY>,
'entity_id': 'fan.air_purifier_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -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)