Skip to content

Commit cd94685

Browse files
Add Fan platform to Switchbot cloud (home-assistant#148304)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 0acfb81 commit cd94685

File tree

4 files changed

+320
-1
lines changed

4 files changed

+320
-1
lines changed

homeassistant/components/switchbot_cloud/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
Platform.BINARY_SENSOR,
3030
Platform.BUTTON,
3131
Platform.CLIMATE,
32+
Platform.FAN,
3233
Platform.LOCK,
3334
Platform.SENSOR,
3435
Platform.SWITCH,
@@ -51,6 +52,7 @@ class SwitchbotDevices:
5152
sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
5253
vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
5354
locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
55+
fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
5456

5557

5658
@dataclass
@@ -96,7 +98,6 @@ async def make_switchbot_devices(
9698
for device in devices
9799
]
98100
)
99-
100101
return devices_data
101102

102103

@@ -177,6 +178,16 @@ async def make_device_data(
177178
else:
178179
devices_data.switches.append((device, coordinator))
179180

181+
if isinstance(device, Device) and device.device_type in [
182+
"Battery Circulator Fan",
183+
"Circulator Fan",
184+
]:
185+
coordinator = await coordinator_for_device(
186+
hass, entry, api, device, coordinators_by_id
187+
)
188+
devices_data.fans.append((device, coordinator))
189+
devices_data.sensors.append((device, coordinator))
190+
180191

181192
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
182193
"""Set up SwitchBot via API from a config entry."""
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Support for the Switchbot Battery Circulator fan."""
2+
3+
import asyncio
4+
from typing import Any
5+
6+
from switchbot_api import (
7+
BatteryCirculatorFanCommands,
8+
BatteryCirculatorFanMode,
9+
CommonCommands,
10+
)
11+
12+
from homeassistant.components.fan import FanEntity, FanEntityFeature
13+
from homeassistant.config_entries import ConfigEntry
14+
from homeassistant.core import HomeAssistant
15+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
16+
17+
from . import SwitchbotCloudData
18+
from .const import DOMAIN
19+
from .entity import SwitchBotCloudEntity
20+
21+
22+
async def async_setup_entry(
23+
hass: HomeAssistant,
24+
config: ConfigEntry,
25+
async_add_entities: AddConfigEntryEntitiesCallback,
26+
) -> None:
27+
"""Set up SwitchBot Cloud entry."""
28+
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
29+
async_add_entities(
30+
SwitchBotCloudFan(data.api, device, coordinator)
31+
for device, coordinator in data.devices.fans
32+
)
33+
34+
35+
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
36+
"""Representation of a SwitchBot Battery Circulator Fan."""
37+
38+
_attr_name = None
39+
40+
_attr_supported_features = (
41+
FanEntityFeature.SET_SPEED
42+
| FanEntityFeature.PRESET_MODE
43+
| FanEntityFeature.TURN_OFF
44+
| FanEntityFeature.TURN_ON
45+
)
46+
_attr_preset_modes = list(BatteryCirculatorFanMode)
47+
48+
_attr_is_on: bool | None = None
49+
50+
@property
51+
def is_on(self) -> bool | None:
52+
"""Return true if the entity is on."""
53+
return self._attr_is_on
54+
55+
def _set_attributes(self) -> None:
56+
"""Set attributes from coordinator data."""
57+
if self.coordinator.data is None:
58+
return
59+
60+
power: str = self.coordinator.data["power"]
61+
mode: str = self.coordinator.data["mode"]
62+
fan_speed: str = self.coordinator.data["fanSpeed"]
63+
self._attr_is_on = power == "on"
64+
self._attr_preset_mode = mode
65+
self._attr_percentage = int(fan_speed)
66+
self._attr_supported_features = (
67+
FanEntityFeature.PRESET_MODE
68+
| FanEntityFeature.TURN_OFF
69+
| FanEntityFeature.TURN_ON
70+
)
71+
if self.is_on and self.preset_mode == BatteryCirculatorFanMode.DIRECT.value:
72+
self._attr_supported_features |= FanEntityFeature.SET_SPEED
73+
74+
async def async_turn_on(
75+
self,
76+
percentage: int | None = None,
77+
preset_mode: str | None = None,
78+
**kwargs: Any,
79+
) -> None:
80+
"""Turn on the fan."""
81+
await self.send_api_command(CommonCommands.ON)
82+
await self.send_api_command(
83+
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
84+
parameters=str(self.preset_mode),
85+
)
86+
if self.preset_mode == BatteryCirculatorFanMode.DIRECT.value:
87+
await self.send_api_command(
88+
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
89+
parameters=str(self.percentage),
90+
)
91+
await asyncio.sleep(5)
92+
await self.coordinator.async_request_refresh()
93+
94+
async def async_turn_off(self, **kwargs: Any) -> None:
95+
"""Turn off the fan."""
96+
await self.send_api_command(CommonCommands.OFF)
97+
await asyncio.sleep(5)
98+
await self.coordinator.async_request_refresh()
99+
100+
async def async_set_percentage(self, percentage: int) -> None:
101+
"""Set the speed of the fan, as a percentage."""
102+
await self.send_api_command(
103+
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
104+
parameters=str(BatteryCirculatorFanMode.DIRECT.value),
105+
)
106+
await self.send_api_command(
107+
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
108+
parameters=str(percentage),
109+
)
110+
await asyncio.sleep(5)
111+
await self.coordinator.async_request_refresh()
112+
113+
async def async_set_preset_mode(self, preset_mode: str) -> None:
114+
"""Set new preset mode."""
115+
await self.send_api_command(
116+
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
117+
parameters=preset_mode,
118+
)
119+
await asyncio.sleep(5)
120+
await self.coordinator.async_request_refresh()

homeassistant/components/switchbot_cloud/sensor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191

9292
SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
9393
"Bot": (BATTERY_DESCRIPTION,),
94+
"Battery Circulator Fan": (BATTERY_DESCRIPTION,),
9495
"Meter": (
9596
TEMPERATURE_DESCRIPTION,
9697
HUMIDITY_DESCRIPTION,
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""Test for the Switchbot Battery Circulator Fan."""
2+
3+
from unittest.mock import patch
4+
5+
from switchbot_api import Device, SwitchBotAPI
6+
7+
from homeassistant.components.fan import (
8+
ATTR_PERCENTAGE,
9+
ATTR_PRESET_MODE,
10+
DOMAIN as FAN_DOMAIN,
11+
SERVICE_SET_PERCENTAGE,
12+
SERVICE_SET_PRESET_MODE,
13+
SERVICE_TURN_ON,
14+
)
15+
from homeassistant.config_entries import ConfigEntryState
16+
from homeassistant.const import (
17+
ATTR_ENTITY_ID,
18+
SERVICE_TURN_OFF,
19+
STATE_OFF,
20+
STATE_ON,
21+
STATE_UNKNOWN,
22+
)
23+
from homeassistant.core import HomeAssistant
24+
25+
from . import configure_integration
26+
27+
28+
async def test_coordinator_data_is_none(
29+
hass: HomeAssistant, mock_list_devices, mock_get_status
30+
) -> None:
31+
"""Test coordinator data is none."""
32+
mock_list_devices.return_value = [
33+
Device(
34+
version="V1.0",
35+
deviceId="battery-fan-id-1",
36+
deviceName="battery-fan-1",
37+
deviceType="Battery Circulator Fan",
38+
hubDeviceId="test-hub-id",
39+
),
40+
]
41+
mock_get_status.side_effect = [
42+
None,
43+
]
44+
entry = await configure_integration(hass)
45+
assert entry.state is ConfigEntryState.LOADED
46+
entity_id = "fan.battery_fan_1"
47+
state = hass.states.get(entity_id)
48+
49+
assert state.state == STATE_UNKNOWN
50+
51+
52+
async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None:
53+
"""Test turning on the fan."""
54+
mock_list_devices.return_value = [
55+
Device(
56+
version="V1.0",
57+
deviceId="battery-fan-id-1",
58+
deviceName="battery-fan-1",
59+
deviceType="Battery Circulator Fan",
60+
hubDeviceId="test-hub-id",
61+
),
62+
]
63+
mock_get_status.side_effect = [
64+
{"power": "off", "mode": "direct", "fanSpeed": "0"},
65+
{"power": "on", "mode": "direct", "fanSpeed": "0"},
66+
{"power": "on", "mode": "direct", "fanSpeed": "0"},
67+
]
68+
entry = await configure_integration(hass)
69+
assert entry.state is ConfigEntryState.LOADED
70+
entity_id = "fan.battery_fan_1"
71+
state = hass.states.get(entity_id)
72+
73+
assert state.state == STATE_OFF
74+
75+
with patch.object(SwitchBotAPI, "send_command") as mock_send_command:
76+
await hass.services.async_call(
77+
FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True
78+
)
79+
mock_send_command.assert_called()
80+
81+
state = hass.states.get(entity_id)
82+
assert state.state == STATE_ON
83+
84+
85+
async def test_turn_off(
86+
hass: HomeAssistant, mock_list_devices, mock_get_status
87+
) -> None:
88+
"""Test turning off the fan."""
89+
mock_list_devices.return_value = [
90+
Device(
91+
version="V1.0",
92+
deviceId="battery-fan-id-1",
93+
deviceName="battery-fan-1",
94+
deviceType="Battery Circulator Fan",
95+
hubDeviceId="test-hub-id",
96+
),
97+
]
98+
mock_get_status.side_effect = [
99+
{"power": "on", "mode": "direct", "fanSpeed": "0"},
100+
{"power": "off", "mode": "direct", "fanSpeed": "0"},
101+
{"power": "off", "mode": "direct", "fanSpeed": "0"},
102+
]
103+
entry = await configure_integration(hass)
104+
assert entry.state is ConfigEntryState.LOADED
105+
entity_id = "fan.battery_fan_1"
106+
state = hass.states.get(entity_id)
107+
108+
assert state.state == STATE_ON
109+
110+
with patch.object(SwitchBotAPI, "send_command") as mock_send_command:
111+
await hass.services.async_call(
112+
FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
113+
)
114+
mock_send_command.assert_called()
115+
116+
state = hass.states.get(entity_id)
117+
assert state.state == STATE_OFF
118+
119+
120+
async def test_set_percentage(
121+
hass: HomeAssistant, mock_list_devices, mock_get_status
122+
) -> None:
123+
"""Test set percentage."""
124+
mock_list_devices.return_value = [
125+
Device(
126+
version="V1.0",
127+
deviceId="battery-fan-id-1",
128+
deviceName="battery-fan-1",
129+
deviceType="Battery Circulator Fan",
130+
hubDeviceId="test-hub-id",
131+
),
132+
]
133+
mock_get_status.side_effect = [
134+
{"power": "on", "mode": "direct", "fanSpeed": "0"},
135+
{"power": "on", "mode": "direct", "fanSpeed": "0"},
136+
{"power": "off", "mode": "direct", "fanSpeed": "5"},
137+
]
138+
entry = await configure_integration(hass)
139+
assert entry.state is ConfigEntryState.LOADED
140+
entity_id = "fan.battery_fan_1"
141+
state = hass.states.get(entity_id)
142+
143+
assert state.state == STATE_ON
144+
145+
with patch.object(SwitchBotAPI, "send_command") as mock_send_command:
146+
await hass.services.async_call(
147+
FAN_DOMAIN,
148+
SERVICE_SET_PERCENTAGE,
149+
{ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 5},
150+
blocking=True,
151+
)
152+
mock_send_command.assert_called()
153+
154+
155+
async def test_set_preset_mode(
156+
hass: HomeAssistant, mock_list_devices, mock_get_status
157+
) -> None:
158+
"""Test set preset mode."""
159+
mock_list_devices.return_value = [
160+
Device(
161+
version="V1.0",
162+
deviceId="battery-fan-id-1",
163+
deviceName="battery-fan-1",
164+
deviceType="Battery Circulator Fan",
165+
hubDeviceId="test-hub-id",
166+
),
167+
]
168+
mock_get_status.side_effect = [
169+
{"power": "on", "mode": "direct", "fanSpeed": "0"},
170+
{"power": "on", "mode": "direct", "fanSpeed": "0"},
171+
{"power": "on", "mode": "baby", "fanSpeed": "0"},
172+
]
173+
entry = await configure_integration(hass)
174+
assert entry.state is ConfigEntryState.LOADED
175+
entity_id = "fan.battery_fan_1"
176+
state = hass.states.get(entity_id)
177+
178+
assert state.state == STATE_ON
179+
180+
with patch.object(SwitchBotAPI, "send_command") as mock_send_command:
181+
await hass.services.async_call(
182+
FAN_DOMAIN,
183+
SERVICE_SET_PRESET_MODE,
184+
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "baby"},
185+
blocking=True,
186+
)
187+
mock_send_command.assert_called_once()

0 commit comments

Comments
 (0)