Skip to content

Commit 2c5ee33

Browse files
Add support for circulator fan (#317)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 258c5f0 commit 2c5ee33

File tree

8 files changed

+442
-0
lines changed

8 files changed

+442
-0
lines changed

switchbot/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from .adv_parser import SwitchbotSupportedType, parse_advertisement_data
1212
from .const import (
13+
FanMode,
1314
LockStatus,
1415
SwitchbotAccountConnectionError,
1516
SwitchbotApiError,
@@ -24,6 +25,7 @@
2425
from .devices.curtain import SwitchbotCurtain
2526
from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice
2627
from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
28+
from .devices.fan import SwitchbotFan
2729
from .devices.humidifier import SwitchbotHumidifier
2830
from .devices.light_strip import SwitchbotLightStrip
2931
from .devices.lock import SwitchbotLock
@@ -35,6 +37,7 @@
3537

3638
__all__ = [
3739
"ColorMode",
40+
"FanMode",
3841
"GetSwitchbotDevices",
3942
"LockStatus",
4043
"SwitchBotAdvertisement",
@@ -51,6 +54,7 @@
5154
"SwitchbotDevice",
5255
"SwitchbotEncryptedDevice",
5356
"SwitchbotEvaporativeHumidifier",
57+
"SwitchbotFan",
5458
"SwitchbotHumidifier",
5559
"SwitchbotLightStrip",
5660
"SwitchbotLock",

switchbot/adv_parser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .adv_parsers.ceiling_light import process_woceiling
1717
from .adv_parsers.contact import process_wocontact
1818
from .adv_parsers.curtain import process_wocurtain
19+
from .adv_parsers.fan import process_fan
1920
from .adv_parsers.hub2 import process_wohub2
2021
from .adv_parsers.hubmini_matter import process_hubmini_matter
2122
from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
@@ -230,6 +231,12 @@ class SwitchbotSupportedType(TypedDict):
230231
"func": process_hubmini_matter,
231232
"manufacturer_id": 2409,
232233
},
234+
"~": {
235+
"modelName": SwitchbotModel.CIRCULATOR_FAN,
236+
"modelFriendlyName": "Circulator Fan",
237+
"func": process_fan,
238+
"manufacturer_id": 2409,
239+
},
233240
}
234241

235242
_SWITCHBOT_MODEL_TO_CHAR = {

switchbot/adv_parsers/fan.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Fan adv parser."""
2+
3+
from __future__ import annotations
4+
5+
from ..const.fan import FanMode
6+
7+
8+
def process_fan(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
9+
"""Process fan services data."""
10+
if mfr_data is None:
11+
return {}
12+
13+
device_data = mfr_data[6:]
14+
15+
_seq_num = device_data[0]
16+
_isOn = bool(device_data[1] & 0b10000000)
17+
_mode = (device_data[1] & 0b01110000) >> 4
18+
_mode = FanMode(_mode).name if 1 <= _mode <= 4 else None
19+
_nightLight = (device_data[1] & 0b00001100) >> 2
20+
_oscillate_left_and_right = bool(device_data[1] & 0b00000010)
21+
_oscillate_up_and_down = bool(device_data[1] & 0b00000001)
22+
_battery = device_data[2] & 0b01111111
23+
_speed = device_data[3] & 0b01111111
24+
25+
return {
26+
"sequence_number": _seq_num,
27+
"isOn": _isOn,
28+
"mode": _mode,
29+
"nightLight": _nightLight,
30+
"oscillating": _oscillate_left_and_right | _oscillate_up_and_down,
31+
"battery": _battery,
32+
"speed": _speed,
33+
}

switchbot/const/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from ..enum import StrEnum
6+
from .fan import FanMode as FanMode
67

78
# Preserve old LockStatus export for backwards compatibility
89
from .lock import LockStatus as LockStatus
@@ -65,3 +66,4 @@ class SwitchbotModel(StrEnum):
6566
EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier"
6667
ROLLER_SHADE = "Roller Shade"
6768
HUBMINI_MATTER = "HubMini Matter"
69+
CIRCULATOR_FAN = "Circulator Fan"

switchbot/const/fan.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
5+
6+
class FanMode(Enum):
7+
NORMAL = 1
8+
NATURAL = 2
9+
SLEEP = 3
10+
BABY = 4
11+
12+
@classmethod
13+
def get_modes(cls) -> list[str]:
14+
return [mode.name for mode in cls]

switchbot/devices/fan.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Library to handle connection with Switchbot."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from ..const.fan import FanMode
9+
from .device import (
10+
DEVICE_GET_BASIC_SETTINGS_KEY,
11+
SwitchbotSequenceDevice,
12+
update_after_operation,
13+
)
14+
15+
_LOGGER = logging.getLogger(__name__)
16+
17+
18+
COMMAND_HEAD = "570f41"
19+
COMMAND_TURN_ON = f"{COMMAND_HEAD}0101"
20+
COMMAND_TURN_OFF = f"{COMMAND_HEAD}0102"
21+
COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}020101ff"
22+
COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}020102ff"
23+
COMMAND_SET_MODE = {
24+
FanMode.NORMAL.name: f"{COMMAND_HEAD}030101ff",
25+
FanMode.NATURAL.name: f"{COMMAND_HEAD}030102ff",
26+
FanMode.SLEEP.name: f"{COMMAND_HEAD}030103",
27+
FanMode.BABY.name: f"{COMMAND_HEAD}030104",
28+
}
29+
COMMAND_SET_PERCENTAGE = f"{COMMAND_HEAD}0302" # +speed
30+
COMMAND_GET_BASIC_INFO = "570f428102"
31+
32+
33+
class SwitchbotFan(SwitchbotSequenceDevice):
34+
"""Representation of a Switchbot Circulator Fan."""
35+
36+
def __init__(self, device, password=None, interface=0, **kwargs):
37+
super().__init__(device, password, interface, **kwargs)
38+
39+
async def get_basic_info(self) -> dict[str, Any] | None:
40+
"""Get device basic settings."""
41+
if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)):
42+
return None
43+
if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
44+
return None
45+
46+
_LOGGER.debug("data: %s", _data)
47+
battery = _data[2] & 0b01111111
48+
isOn = bool(_data[3] & 0b10000000)
49+
oscillating = bool(_data[3] & 0b01100000)
50+
_mode = _data[8] & 0b00000111
51+
mode = FanMode(_mode).name if 1 <= _mode <= 4 else None
52+
speed = _data[9]
53+
firmware = _data1[2] / 10.0
54+
55+
return {
56+
"battery": battery,
57+
"isOn": isOn,
58+
"oscillating": oscillating,
59+
"mode": mode,
60+
"speed": speed,
61+
"firmware": firmware,
62+
}
63+
64+
async def _get_basic_info(self, cmd: str) -> bytes | None:
65+
"""Return basic info of device."""
66+
_data = await self._send_command(key=cmd, retry=self._retry_count)
67+
68+
if _data in (b"\x07", b"\x00"):
69+
_LOGGER.error("Unsuccessful, please try again")
70+
return None
71+
72+
return _data
73+
74+
@update_after_operation
75+
async def set_preset_mode(self, preset_mode: str) -> bool:
76+
"""Send command to set fan preset_mode."""
77+
return await self._send_command(COMMAND_SET_MODE[preset_mode])
78+
79+
@update_after_operation
80+
async def set_percentage(self, percentage: int) -> bool:
81+
"""Send command to set fan percentage."""
82+
return await self._send_command(f"{COMMAND_SET_PERCENTAGE}{percentage:02X}")
83+
84+
@update_after_operation
85+
async def set_oscillation(self, oscillating: bool) -> bool:
86+
"""Send command to set fan oscillation"""
87+
if oscillating:
88+
return await self._send_command(COMMAND_START_OSCILLATION)
89+
else:
90+
return await self._send_command(COMMAND_STOP_OSCILLATION)
91+
92+
@update_after_operation
93+
async def turn_on(self) -> bool:
94+
"""Turn on the fan."""
95+
return await self._send_command(COMMAND_TURN_ON)
96+
97+
@update_after_operation
98+
async def turn_off(self) -> bool:
99+
"""Turn off the fan."""
100+
return await self._send_command(COMMAND_TURN_OFF)
101+
102+
def get_current_percentage(self) -> Any:
103+
"""Return cached percentage."""
104+
return self._get_adv_value("speed")
105+
106+
def is_on(self) -> bool | None:
107+
"""Return fan state from cache."""
108+
return self._get_adv_value("isOn")
109+
110+
def get_oscillating_state(self) -> Any:
111+
"""Return cached oscillating."""
112+
return self._get_adv_value("oscillating")
113+
114+
def get_current_mode(self) -> Any:
115+
"""Return cached mode."""
116+
return self._get_adv_value("mode")

tests/test_adv_parser.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2103,3 +2103,97 @@ def test_roller_shade_passive() -> None:
21032103
rssi=-97,
21042104
active=False,
21052105
)
2106+
2107+
2108+
def test_circulator_fan_active() -> None:
2109+
"""Test parsing circulator fan with active data."""
2110+
ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2111+
adv_data = generate_advertisement_data(
2112+
manufacturer_data={2409: b"\xb0\xe9\xfeXY\xa8~LR9"},
2113+
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"},
2114+
rssi=-97,
2115+
)
2116+
result = parse_advertisement_data(
2117+
ble_device, adv_data, SwitchbotModel.CIRCULATOR_FAN
2118+
)
2119+
assert result == SwitchBotAdvertisement(
2120+
address="aa:bb:cc:dd:ee:ff",
2121+
data={
2122+
"rawAdvData": b"~\x00R",
2123+
"data": {
2124+
"sequence_number": 126,
2125+
"isOn": False,
2126+
"mode": "BABY",
2127+
"nightLight": 3,
2128+
"oscillating": False,
2129+
"battery": 82,
2130+
"speed": 57,
2131+
},
2132+
"isEncrypted": False,
2133+
"model": "~",
2134+
"modelFriendlyName": "Circulator Fan",
2135+
"modelName": SwitchbotModel.CIRCULATOR_FAN,
2136+
},
2137+
device=ble_device,
2138+
rssi=-97,
2139+
active=True,
2140+
)
2141+
2142+
2143+
def test_circulator_fan_passive() -> None:
2144+
"""Test parsing circulator fan with passive data."""
2145+
ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2146+
adv_data = generate_advertisement_data(
2147+
manufacturer_data={2409: b"\xb0\xe9\xfeXY\xa8~LR9"},
2148+
rssi=-97,
2149+
)
2150+
result = parse_advertisement_data(
2151+
ble_device, adv_data, SwitchbotModel.CIRCULATOR_FAN
2152+
)
2153+
assert result == SwitchBotAdvertisement(
2154+
address="aa:bb:cc:dd:ee:ff",
2155+
data={
2156+
"rawAdvData": None,
2157+
"data": {
2158+
"sequence_number": 126,
2159+
"isOn": False,
2160+
"mode": "BABY",
2161+
"nightLight": 3,
2162+
"oscillating": False,
2163+
"battery": 82,
2164+
"speed": 57,
2165+
},
2166+
"isEncrypted": False,
2167+
"model": "~",
2168+
"modelFriendlyName": "Circulator Fan",
2169+
"modelName": SwitchbotModel.CIRCULATOR_FAN,
2170+
},
2171+
device=ble_device,
2172+
rssi=-97,
2173+
active=False,
2174+
)
2175+
2176+
2177+
def test_circulator_fan_with_empty_data() -> None:
2178+
"""Test parsing circulator fan with empty data."""
2179+
ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2180+
adv_data = generate_advertisement_data(
2181+
manufacturer_data={2409: None},
2182+
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"},
2183+
rssi=-97,
2184+
)
2185+
result = parse_advertisement_data(
2186+
ble_device, adv_data, SwitchbotModel.CIRCULATOR_FAN
2187+
)
2188+
assert result == SwitchBotAdvertisement(
2189+
address="aa:bb:cc:dd:ee:ff",
2190+
data={
2191+
"rawAdvData": b"~\x00R",
2192+
"data": {},
2193+
"isEncrypted": False,
2194+
"model": "~",
2195+
},
2196+
device=ble_device,
2197+
rssi=-97,
2198+
active=True,
2199+
)

0 commit comments

Comments
 (0)