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)) devices_data.buttons.append((device, coordinator))
else: else:
devices_data.switches.append((device, coordinator)) 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 [ if isinstance(device, Device) and device.device_type in [
"Battery Circulator Fan", "Battery Circulator Fan",

View File

@@ -1,6 +1,7 @@
"""Constants for the SwitchBot Cloud integration.""" """Constants for the SwitchBot Cloud integration."""
from datetime import timedelta from datetime import timedelta
from enum import Enum
from typing import Final from typing import Final
DOMAIN: Final = "switchbot_cloud" DOMAIN: Final = "switchbot_cloud"
@@ -17,5 +18,18 @@ VACUUM_FAN_SPEED_STRONG = "strong"
VACUUM_FAN_SPEED_MAX = "max" VACUUM_FAN_SPEED_MAX = "max"
AFTER_COMMAND_REFRESH = 5 AFTER_COMMAND_REFRESH = 5
COVER_ENTITY_AFTER_COMMAND_REFRESH = 10 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.""" """Support for the Switchbot Battery Circulator fan."""
import asyncio import asyncio
import logging
from typing import Any from typing import Any
from switchbot_api import ( from switchbot_api import (
AirPurifierCommands,
BatteryCirculatorFanCommands, BatteryCirculatorFanCommands,
BatteryCirculatorFanMode, BatteryCirculatorFanMode,
CommonCommands, CommonCommands,
SwitchBotAPI,
) )
from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData from . import SwitchbotCloudData
from .const import AFTER_COMMAND_REFRESH, DOMAIN from .const import AFTER_COMMAND_REFRESH, DOMAIN, AirPurifierMode
from .entity import SwitchBotCloudEntity from .entity import SwitchBotCloudEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -26,10 +33,13 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up SwitchBot Cloud entry.""" """Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
for device, coordinator in data.devices.fans:
if device.device_type.startswith("Air Purifier"):
async_add_entities( async_add_entities(
SwitchBotCloudFan(data.api, device, coordinator) [SwitchBotAirPurifierEntity(data.api, device, coordinator)]
for device, coordinator in data.devices.fans
) )
else:
async_add_entities([SwitchBotCloudFan(data.api, device, coordinator)])
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
@@ -37,6 +47,7 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
_attr_name = None _attr_name = None
_api: SwitchBotAPI
_attr_supported_features = ( _attr_supported_features = (
FanEntityFeature.SET_SPEED FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE | FanEntityFeature.PRESET_MODE
@@ -118,3 +129,75 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
) )
await asyncio.sleep(AFTER_COMMAND_REFRESH) await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_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": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "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.""" """Tests for the SwitchBot Cloud integration."""
from switchbot_api import Device
from homeassistant.components.switchbot_cloud.const import DOMAIN from homeassistant.components.switchbot_cloud.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -21,3 +23,20 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry:
await hass.async_block_till_done() await hass.async_block_till_done()
return entry 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 from unittest.mock import patch
import pytest
import switchbot_api
from switchbot_api import Device, SwitchBotAPI from switchbot_api import Device, SwitchBotAPI
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fan import ( from homeassistant.components.fan import (
ATTR_PERCENTAGE, ATTR_PERCENTAGE,
@@ -12,6 +15,7 @@ from homeassistant.components.fan import (
SERVICE_SET_PRESET_MODE, SERVICE_SET_PRESET_MODE,
SERVICE_TURN_ON, SERVICE_TURN_ON,
) )
from homeassistant.components.switchbot_cloud.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
@@ -19,32 +23,37 @@ from homeassistant.const import (
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
STATE_UNKNOWN, STATE_UNKNOWN,
Platform,
) )
from homeassistant.core import HomeAssistant 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( 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: ) -> None:
"""Test coordinator data is none.""" """Test coordinator data is none."""
mock_list_devices.return_value = [ mock_list_devices.return_value = [device_info]
Device( mock_get_status.side_effect = [None]
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,
]
entry = await configure_integration(hass) entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED assert entry.state is ConfigEntryState.LOADED
entity_id = "fan.battery_fan_1" state = hass.states.get(entry_id)
state = hass.states.get(entity_id)
assert state.state == STATE_UNKNOWN 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: async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None:
"""Test turning on the fan.""" """Test turning on the fan."""
mock_list_devices.return_value = [ mock_list_devices.return_value = [
Device( CIRCULATOR_FAN_INFO,
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 = [ mock_get_status.side_effect = [
{"power": "off", "mode": "direct", "fanSpeed": "0"}, {"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 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( await hass.services.async_call(
FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True
) )
@@ -87,13 +92,7 @@ async def test_turn_off(
) -> None: ) -> None:
"""Test turning off the fan.""" """Test turning off the fan."""
mock_list_devices.return_value = [ mock_list_devices.return_value = [
Device( CIRCULATOR_FAN_INFO,
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 = [ mock_get_status.side_effect = [
{"power": "on", "mode": "direct", "fanSpeed": "0"}, {"power": "on", "mode": "direct", "fanSpeed": "0"},
@@ -107,7 +106,9 @@ async def test_turn_off(
assert state.state == STATE_ON 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( await hass.services.async_call(
FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
) )
@@ -122,13 +123,7 @@ async def test_set_percentage(
) -> None: ) -> None:
"""Test set percentage.""" """Test set percentage."""
mock_list_devices.return_value = [ mock_list_devices.return_value = [
Device( CIRCULATOR_FAN_INFO,
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 = [ mock_get_status.side_effect = [
{"power": "on", "mode": "direct", "fanSpeed": "0"}, {"power": "on", "mode": "direct", "fanSpeed": "0"},
@@ -142,7 +137,9 @@ async def test_set_percentage(
assert state.state == STATE_ON 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( await hass.services.async_call(
FAN_DOMAIN, FAN_DOMAIN,
SERVICE_SET_PERCENTAGE, SERVICE_SET_PERCENTAGE,
@@ -157,13 +154,7 @@ async def test_set_preset_mode(
) -> None: ) -> None:
"""Test set preset mode.""" """Test set preset mode."""
mock_list_devices.return_value = [ mock_list_devices.return_value = [
Device( CIRCULATOR_FAN_INFO,
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 = [ mock_get_status.side_effect = [
{"power": "on", "mode": "direct", "fanSpeed": "0"}, {"power": "on", "mode": "direct", "fanSpeed": "0"},
@@ -177,7 +168,9 @@ async def test_set_preset_mode(
assert state.state == STATE_ON 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( await hass.services.async_call(
FAN_DOMAIN, FAN_DOMAIN,
SERVICE_SET_PRESET_MODE, SERVICE_SET_PRESET_MODE,
@@ -185,3 +178,86 @@ async def test_set_preset_mode(
blocking=True, blocking=True,
) )
mock_send_command.assert_called_once() 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)