mirror of
https://github.com/home-assistant/core.git
synced 2025-09-05 21:01:37 +02:00
Add air purifier for switchbot cloud integration (#147001)
This commit is contained in:
@@ -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",
|
||||
|
@@ -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]
|
||||
|
@@ -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()
|
||||
|
22
homeassistant/components/switchbot_cloud/icons.json
Normal file
22
homeassistant/components/switchbot_cloud/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
)
|
||||
|
@@ -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"
|
||||
}
|
64
tests/components/switchbot_cloud/snapshots/test_fan.ambr
Normal file
64
tests/components/switchbot_cloud/snapshots/test_fan.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user