Skip to content

Commit ed39b18

Browse files
authored
Add cover platform for switchbot cloud (home-assistant#148993)
1 parent 9999807 commit ed39b18

File tree

8 files changed

+739
-5
lines changed

8 files changed

+739
-5
lines changed

homeassistant/components/switchbot_cloud/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
Platform.BINARY_SENSOR,
3030
Platform.BUTTON,
3131
Platform.CLIMATE,
32+
Platform.COVER,
3233
Platform.FAN,
3334
Platform.LIGHT,
3435
Platform.LOCK,
@@ -47,6 +48,7 @@ class SwitchbotDevices:
4748
)
4849
buttons: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
4950
climates: list[tuple[Remote, SwitchBotCoordinator]] = field(default_factory=list)
51+
covers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
5052
switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field(
5153
default_factory=list
5254
)
@@ -192,6 +194,27 @@ async def make_device_data(
192194
)
193195
devices_data.fans.append((device, coordinator))
194196
devices_data.sensors.append((device, coordinator))
197+
if isinstance(device, Device) and device.device_type in [
198+
"Curtain",
199+
"Curtain3",
200+
"Roller Shade",
201+
"Blind Tilt",
202+
]:
203+
coordinator = await coordinator_for_device(
204+
hass, entry, api, device, coordinators_by_id
205+
)
206+
devices_data.covers.append((device, coordinator))
207+
devices_data.binary_sensors.append((device, coordinator))
208+
devices_data.sensors.append((device, coordinator))
209+
210+
if isinstance(device, Device) and device.device_type in [
211+
"Garage Door Opener",
212+
]:
213+
coordinator = await coordinator_for_device(
214+
hass, entry, api, device, coordinators_by_id
215+
)
216+
devices_data.covers.append((device, coordinator))
217+
devices_data.binary_sensors.append((device, coordinator))
195218

196219
if isinstance(device, Device) and device.device_type in [
197220
"Strip Light",

homeassistant/components/switchbot_cloud/binary_sensor.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription)
6060
CALIBRATION_DESCRIPTION,
6161
DOOR_OPEN_DESCRIPTION,
6262
),
63+
"Curtain": (CALIBRATION_DESCRIPTION,),
64+
"Curtain3": (CALIBRATION_DESCRIPTION,),
65+
"Roller Shade": (CALIBRATION_DESCRIPTION,),
66+
"Blind Tilt": (CALIBRATION_DESCRIPTION,),
67+
"Garage Door Opener": (DOOR_OPEN_DESCRIPTION,),
6368
}
6469

6570

homeassistant/components/switchbot_cloud/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@
1717
VACUUM_FAN_SPEED_MAX = "max"
1818

1919
AFTER_COMMAND_REFRESH = 5
20+
21+
COVER_ENTITY_AFTER_COMMAND_REFRESH = 10
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""Support for the Switchbot BlindTilt, Curtain, Curtain3, RollerShade as Cover."""
2+
3+
import asyncio
4+
from typing import Any
5+
6+
from switchbot_api import (
7+
BlindTiltCommands,
8+
CommonCommands,
9+
CurtainCommands,
10+
Device,
11+
Remote,
12+
RollerShadeCommands,
13+
SwitchBotAPI,
14+
)
15+
16+
from homeassistant.components.cover import (
17+
CoverDeviceClass,
18+
CoverEntity,
19+
CoverEntityFeature,
20+
)
21+
from homeassistant.config_entries import ConfigEntry
22+
from homeassistant.core import HomeAssistant, callback
23+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
24+
25+
from . import SwitchbotCloudData, SwitchBotCoordinator
26+
from .const import COVER_ENTITY_AFTER_COMMAND_REFRESH, DOMAIN
27+
from .entity import SwitchBotCloudEntity
28+
29+
30+
async def async_setup_entry(
31+
hass: HomeAssistant,
32+
config: ConfigEntry,
33+
async_add_entities: AddConfigEntryEntitiesCallback,
34+
) -> None:
35+
"""Set up SwitchBot Cloud entry."""
36+
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
37+
async_add_entities(
38+
_async_make_entity(data.api, device, coordinator)
39+
for device, coordinator in data.devices.covers
40+
)
41+
42+
43+
class SwitchBotCloudCover(SwitchBotCloudEntity, CoverEntity):
44+
"""Representation of a SwitchBot Cover."""
45+
46+
_attr_name = None
47+
_attr_is_closed: bool | None = None
48+
49+
def _set_attributes(self) -> None:
50+
if self.coordinator.data is None:
51+
return
52+
position: int | None = self.coordinator.data.get("slidePosition")
53+
if position is None:
54+
return
55+
self._attr_current_cover_position = 100 - position
56+
self._attr_current_cover_tilt_position = 100 - position
57+
self._attr_is_closed = position == 100
58+
59+
60+
class SwitchBotCloudCoverCurtain(SwitchBotCloudCover):
61+
"""Representation of a SwitchBot Curtain & Curtain3."""
62+
63+
_attr_device_class = CoverDeviceClass.CURTAIN
64+
_attr_supported_features: CoverEntityFeature = (
65+
CoverEntityFeature.OPEN
66+
| CoverEntityFeature.CLOSE
67+
| CoverEntityFeature.STOP
68+
| CoverEntityFeature.SET_POSITION
69+
)
70+
71+
async def async_open_cover(self, **kwargs: Any) -> None:
72+
"""Open the cover."""
73+
await self.send_api_command(CommonCommands.ON)
74+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
75+
await self.coordinator.async_request_refresh()
76+
77+
async def async_close_cover(self, **kwargs: Any) -> None:
78+
"""Close the cover."""
79+
await self.send_api_command(CommonCommands.OFF)
80+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
81+
await self.coordinator.async_request_refresh()
82+
83+
async def async_set_cover_position(self, **kwargs: Any) -> None:
84+
"""Move the cover to a specific position."""
85+
position: int | None = kwargs.get("position")
86+
if position is not None:
87+
await self.send_api_command(
88+
CurtainCommands.SET_POSITION,
89+
parameters=f"{0},ff,{100 - position}",
90+
)
91+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
92+
await self.coordinator.async_request_refresh()
93+
94+
async def async_stop_cover(self, **kwargs: Any) -> None:
95+
"""Stop the cover."""
96+
await self.send_api_command(CurtainCommands.PAUSE)
97+
await self.coordinator.async_request_refresh()
98+
99+
100+
class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover):
101+
"""Representation of a SwitchBot RollerShade."""
102+
103+
_attr_device_class = CoverDeviceClass.SHADE
104+
_attr_supported_features: CoverEntityFeature = (
105+
CoverEntityFeature.SET_POSITION
106+
| CoverEntityFeature.OPEN
107+
| CoverEntityFeature.CLOSE
108+
)
109+
110+
async def async_open_cover(self, **kwargs: Any) -> None:
111+
"""Open the cover."""
112+
await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=str(0))
113+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
114+
await self.coordinator.async_request_refresh()
115+
116+
async def async_close_cover(self, **kwargs: Any) -> None:
117+
"""Close the cover."""
118+
await self.send_api_command(
119+
RollerShadeCommands.SET_POSITION, parameters=str(100)
120+
)
121+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
122+
await self.coordinator.async_request_refresh()
123+
124+
async def async_set_cover_position(self, **kwargs: Any) -> None:
125+
"""Move the cover to a specific position."""
126+
position: int | None = kwargs.get("position")
127+
if position is not None:
128+
await self.send_api_command(
129+
RollerShadeCommands.SET_POSITION, parameters=str(100 - position)
130+
)
131+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
132+
await self.coordinator.async_request_refresh()
133+
134+
135+
class SwitchBotCloudCoverBlindTilt(SwitchBotCloudCover):
136+
"""Representation of a SwitchBot Blind Tilt."""
137+
138+
_attr_direction: str | None = None
139+
_attr_device_class = CoverDeviceClass.BLIND
140+
_attr_supported_features: CoverEntityFeature = (
141+
CoverEntityFeature.SET_TILT_POSITION
142+
| CoverEntityFeature.OPEN_TILT
143+
| CoverEntityFeature.CLOSE_TILT
144+
)
145+
146+
def _set_attributes(self) -> None:
147+
if self.coordinator.data is None:
148+
return
149+
position: int | None = self.coordinator.data.get("slidePosition")
150+
if position is None:
151+
return
152+
self._attr_is_closed = position in [0, 100]
153+
if position > 50:
154+
percent = 100 - ((position - 50) * 2)
155+
else:
156+
percent = 100 - (50 - position) * 2
157+
self._attr_current_cover_position = percent
158+
self._attr_current_cover_tilt_position = percent
159+
direction = self.coordinator.data.get("direction")
160+
self._attr_direction = direction.lower() if direction else None
161+
162+
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
163+
"""Move the cover to a specific position."""
164+
percent: int | None = kwargs.get("tilt_position")
165+
if percent is not None:
166+
await self.send_api_command(
167+
BlindTiltCommands.SET_POSITION,
168+
parameters=f"{self._attr_direction};{percent}",
169+
)
170+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
171+
await self.coordinator.async_request_refresh()
172+
173+
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
174+
"""Open the cover."""
175+
await self.send_api_command(BlindTiltCommands.FULLY_OPEN)
176+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
177+
await self.coordinator.async_request_refresh()
178+
179+
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
180+
"""Close the cover."""
181+
if self._attr_direction is not None:
182+
if "up" in self._attr_direction:
183+
await self.send_api_command(BlindTiltCommands.CLOSE_UP)
184+
else:
185+
await self.send_api_command(BlindTiltCommands.CLOSE_DOWN)
186+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
187+
await self.coordinator.async_request_refresh()
188+
189+
190+
class SwitchBotCloudCoverGarageDoorOpener(SwitchBotCloudCover):
191+
"""Representation of a SwitchBot Garage Door Opener."""
192+
193+
_attr_device_class = CoverDeviceClass.GARAGE
194+
_attr_supported_features: CoverEntityFeature = (
195+
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
196+
)
197+
198+
def _set_attributes(self) -> None:
199+
if self.coordinator.data is None:
200+
return
201+
door_status: int | None = self.coordinator.data.get("doorStatus")
202+
self._attr_is_closed = None if door_status is None else door_status == 1
203+
204+
async def async_open_cover(self, **kwargs: Any) -> None:
205+
"""Open the cover."""
206+
await self.send_api_command(CommonCommands.ON)
207+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
208+
await self.coordinator.async_request_refresh()
209+
210+
async def async_close_cover(self, **kwargs: Any) -> None:
211+
"""Close the cover."""
212+
await self.send_api_command(CommonCommands.OFF)
213+
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
214+
await self.coordinator.async_request_refresh()
215+
216+
217+
@callback
218+
def _async_make_entity(
219+
api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator
220+
) -> (
221+
SwitchBotCloudCoverBlindTilt
222+
| SwitchBotCloudCoverRollerShade
223+
| SwitchBotCloudCoverCurtain
224+
| SwitchBotCloudCoverGarageDoorOpener
225+
):
226+
"""Make a SwitchBotCloudCover device."""
227+
if device.device_type == "Blind Tilt":
228+
return SwitchBotCloudCoverBlindTilt(api, device, coordinator)
229+
if device.device_type == "Roller Shade":
230+
return SwitchBotCloudCoverRollerShade(api, device, coordinator)
231+
if device.device_type == "Garage Door Opener":
232+
return SwitchBotCloudCoverGarageDoorOpener(api, device, coordinator)
233+
return SwitchBotCloudCoverCurtain(api, device, coordinator)

homeassistant/components/switchbot_cloud/fan.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1616

1717
from . import SwitchbotCloudData
18-
from .const import DOMAIN
18+
from .const import AFTER_COMMAND_REFRESH, DOMAIN
1919
from .entity import SwitchBotCloudEntity
2020

2121

@@ -88,13 +88,13 @@ async def async_turn_on(
8888
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
8989
parameters=str(self.percentage),
9090
)
91-
await asyncio.sleep(5)
91+
await asyncio.sleep(AFTER_COMMAND_REFRESH)
9292
await self.coordinator.async_request_refresh()
9393

9494
async def async_turn_off(self, **kwargs: Any) -> None:
9595
"""Turn off the fan."""
9696
await self.send_api_command(CommonCommands.OFF)
97-
await asyncio.sleep(5)
97+
await asyncio.sleep(AFTER_COMMAND_REFRESH)
9898
await self.coordinator.async_request_refresh()
9999

100100
async def async_set_percentage(self, percentage: int) -> None:
@@ -107,7 +107,7 @@ async def async_set_percentage(self, percentage: int) -> None:
107107
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
108108
parameters=str(percentage),
109109
)
110-
await asyncio.sleep(5)
110+
await asyncio.sleep(AFTER_COMMAND_REFRESH)
111111
await self.coordinator.async_request_refresh()
112112

113113
async def async_set_preset_mode(self, preset_mode: str) -> None:
@@ -116,5 +116,5 @@ async def async_set_preset_mode(self, preset_mode: str) -> None:
116116
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
117117
parameters=preset_mode,
118118
)
119-
await asyncio.sleep(5)
119+
await asyncio.sleep(AFTER_COMMAND_REFRESH)
120120
await self.coordinator.async_request_refresh()

homeassistant/components/switchbot_cloud/sensor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@
139139
"Smart Lock Lite": (BATTERY_DESCRIPTION,),
140140
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
141141
"Smart Lock Ultra": (BATTERY_DESCRIPTION,),
142+
"Curtain": (BATTERY_DESCRIPTION,),
143+
"Curtain3": (BATTERY_DESCRIPTION,),
144+
"Roller Shade": (BATTERY_DESCRIPTION,),
145+
"Blind Tilt": (BATTERY_DESCRIPTION,),
142146
}
143147

144148

tests/components/switchbot_cloud/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,13 @@ def mock_after_command_refresh():
3939
"homeassistant.components.switchbot_cloud.const.AFTER_COMMAND_REFRESH", 0
4040
):
4141
yield
42+
43+
44+
@pytest.fixture(scope="package", autouse=True)
45+
def mock_after_command_refresh_for_cover():
46+
"""Mock after command refresh."""
47+
with patch(
48+
"homeassistant.components.switchbot_cloud.const.COVER_ENTITY_AFTER_COMMAND_REFRESH",
49+
0,
50+
):
51+
yield

0 commit comments

Comments
 (0)