Add Fan platform to Switchbot cloud (#148304)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Samuel Xiao 2025-07-15 19:55:13 +08:00 committed by GitHub
parent 0acfb81d50
commit cd94685b7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 320 additions and 1 deletions

View File

@ -29,6 +29,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.CLIMATE, Platform.CLIMATE,
Platform.FAN,
Platform.LOCK, Platform.LOCK,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
@ -51,6 +52,7 @@ class SwitchbotDevices:
sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
@dataclass @dataclass
@ -96,7 +98,6 @@ async def make_switchbot_devices(
for device in devices for device in devices
] ]
) )
return devices_data return devices_data
@ -177,6 +178,16 @@ async def make_device_data(
else: else:
devices_data.switches.append((device, coordinator)) devices_data.switches.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Battery Circulator Fan",
"Circulator Fan",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.fans.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SwitchBot via API from a config entry.""" """Set up SwitchBot via API from a config entry."""

View File

@ -0,0 +1,120 @@
"""Support for the Switchbot Battery Circulator fan."""
import asyncio
from typing import Any
from switchbot_api import (
BatteryCirculatorFanCommands,
BatteryCirculatorFanMode,
CommonCommands,
)
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import DOMAIN
from .entity import SwitchBotCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> 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
)
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
"""Representation of a SwitchBot Battery Circulator Fan."""
_attr_name = None
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_attr_preset_modes = list(BatteryCirculatorFanMode)
_attr_is_on: bool | None = None
@property
def is_on(self) -> bool | None:
"""Return true if the entity is on."""
return self._attr_is_on
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if self.coordinator.data is None:
return
power: str = self.coordinator.data["power"]
mode: str = self.coordinator.data["mode"]
fan_speed: str = self.coordinator.data["fanSpeed"]
self._attr_is_on = power == "on"
self._attr_preset_mode = mode
self._attr_percentage = int(fan_speed)
self._attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
if self.is_on and self.preset_mode == BatteryCirculatorFanMode.DIRECT.value:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
await self.send_api_command(CommonCommands.ON)
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
parameters=str(self.preset_mode),
)
if self.preset_mode == BatteryCirculatorFanMode.DIRECT.value:
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
parameters=str(self.percentage),
)
await asyncio.sleep(5)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(5)
await self.coordinator.async_request_refresh()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
parameters=str(BatteryCirculatorFanMode.DIRECT.value),
)
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
parameters=str(percentage),
)
await asyncio.sleep(5)
await self.coordinator.async_request_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
parameters=preset_mode,
)
await asyncio.sleep(5)
await self.coordinator.async_request_refresh()

View File

@ -91,6 +91,7 @@ CO2_DESCRIPTION = SensorEntityDescription(
SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Bot": (BATTERY_DESCRIPTION,), "Bot": (BATTERY_DESCRIPTION,),
"Battery Circulator Fan": (BATTERY_DESCRIPTION,),
"Meter": ( "Meter": (
TEMPERATURE_DESCRIPTION, TEMPERATURE_DESCRIPTION,
HUMIDITY_DESCRIPTION, HUMIDITY_DESCRIPTION,

View File

@ -0,0 +1,187 @@
"""Test for the Switchbot Battery Circulator Fan."""
from unittest.mock import patch
from switchbot_api import Device, SwitchBotAPI
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
DOMAIN as FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
SERVICE_SET_PRESET_MODE,
SERVICE_TURN_ON,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from . import configure_integration
async def test_coordinator_data_is_none(
hass: HomeAssistant, mock_list_devices, mock_get_status
) -> 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,
]
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
entity_id = "fan.battery_fan_1"
state = hass.states.get(entity_id)
assert state.state == STATE_UNKNOWN
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",
),
]
mock_get_status.side_effect = [
{"power": "off", "mode": "direct", "fanSpeed": "0"},
{"power": "on", "mode": "direct", "fanSpeed": "0"},
{"power": "on", "mode": "direct", "fanSpeed": "0"},
]
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
entity_id = "fan.battery_fan_1"
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
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
)
mock_send_command.assert_called()
state = hass.states.get(entity_id)
assert state.state == STATE_ON
async def test_turn_off(
hass: HomeAssistant, mock_list_devices, mock_get_status
) -> 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",
),
]
mock_get_status.side_effect = [
{"power": "on", "mode": "direct", "fanSpeed": "0"},
{"power": "off", "mode": "direct", "fanSpeed": "0"},
{"power": "off", "mode": "direct", "fanSpeed": "0"},
]
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
entity_id = "fan.battery_fan_1"
state = hass.states.get(entity_id)
assert state.state == STATE_ON
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
)
mock_send_command.assert_called()
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
async def test_set_percentage(
hass: HomeAssistant, mock_list_devices, mock_get_status
) -> 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",
),
]
mock_get_status.side_effect = [
{"power": "on", "mode": "direct", "fanSpeed": "0"},
{"power": "on", "mode": "direct", "fanSpeed": "0"},
{"power": "off", "mode": "direct", "fanSpeed": "5"},
]
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
entity_id = "fan.battery_fan_1"
state = hass.states.get(entity_id)
assert state.state == STATE_ON
with patch.object(SwitchBotAPI, "send_command") as mock_send_command:
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 5},
blocking=True,
)
mock_send_command.assert_called()
async def test_set_preset_mode(
hass: HomeAssistant, mock_list_devices, mock_get_status
) -> 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",
),
]
mock_get_status.side_effect = [
{"power": "on", "mode": "direct", "fanSpeed": "0"},
{"power": "on", "mode": "direct", "fanSpeed": "0"},
{"power": "on", "mode": "baby", "fanSpeed": "0"},
]
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
entity_id = "fan.battery_fan_1"
state = hass.states.get(entity_id)
assert state.state == STATE_ON
with patch.object(SwitchBotAPI, "send_command") as mock_send_command:
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "baby"},
blocking=True,
)
mock_send_command.assert_called_once()