Skip to content

Commit 98b42dc

Browse files
dsypniewskipre-commit-ci[bot]bdraco
authored
Add support for Evaporative Humidifier (#296)
* Add support for Evaporative Humidifier * Formatting * chore(pre-commit.ci): auto fixes * Fix comments * Advertisement parser improvements * Improved device model * Encryption related methods moved to base class * chore(pre-commit.ci): auto fixes * Update * Add tests * chore(pre-commit.ci): auto fixes * chore(pre-commit.ci): auto fixes * chore: address some of the lint issues * Extract constants * Fix sleep command * Improve tests * chore(pre-commit.ci): auto fixes * Fix tests * Fix return types * Move humidifier consts to separate module * Add basic info command --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston <[email protected]>
1 parent 3479998 commit 98b42dc

File tree

8 files changed

+528
-1
lines changed

8 files changed

+528
-1
lines changed

switchbot/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .devices.ceiling_light import SwitchbotCeilingLight
2424
from .devices.curtain import SwitchbotCurtain
2525
from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice
26+
from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
2627
from .devices.humidifier import SwitchbotHumidifier
2728
from .devices.light_strip import SwitchbotLightStrip
2829
from .devices.lock import SwitchbotLock
@@ -37,6 +38,7 @@
3738
"LockStatus",
3839
"SwitchBotAdvertisement",
3940
"Switchbot",
41+
"Switchbot",
4042
"SwitchbotAccountConnectionError",
4143
"SwitchbotApiError",
4244
"SwitchbotAuthenticationError",
@@ -47,13 +49,17 @@
4749
"SwitchbotCurtain",
4850
"SwitchbotDevice",
4951
"SwitchbotEncryptedDevice",
52+
"SwitchbotEvaporativeHumidifier",
5053
"SwitchbotHumidifier",
5154
"SwitchbotLightStrip",
5255
"SwitchbotLock",
5356
"SwitchbotModel",
57+
"SwitchbotModel",
58+
"SwitchbotPlugMini",
5459
"SwitchbotPlugMini",
5560
"SwitchbotRelaySwitch",
5661
"SwitchbotSupportedType",
62+
"SwitchbotSupportedType",
5763
"close_stale_connections",
5864
"close_stale_connections_by_address",
5965
"get_device",

switchbot/adv_parser.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from .adv_parsers.contact import process_wocontact
1818
from .adv_parsers.curtain import process_wocurtain
1919
from .adv_parsers.hub2 import process_wohub2
20-
from .adv_parsers.humidifier import process_wohumidifier
20+
from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
2121
from .adv_parsers.keypad import process_wokeypad
2222
from .adv_parsers.leak import process_leak
2323
from .adv_parsers.light_strip import process_wostrip
@@ -164,6 +164,12 @@ class SwitchbotSupportedType(TypedDict):
164164
"manufacturer_id": 741,
165165
"manufacturer_data_length": 6,
166166
},
167+
"#": {
168+
"modelName": SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
169+
"modelFriendlyName": "Evaporative Humidifier",
170+
"func": process_evaporative_humidifier,
171+
"manufacturer_id": 2409,
172+
},
167173
"o": {
168174
"modelName": SwitchbotModel.LOCK,
169175
"modelFriendlyName": "Lock",

switchbot/adv_parsers/humidifier.py

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

55
import logging
6+
from datetime import timedelta
7+
8+
from ..const.evaporative_humidifier import (
9+
OVER_HUMIDIFY_PROTECTION_MODES,
10+
TARGET_HUMIDITY_MODES,
11+
HumidifierMode,
12+
HumidifierWaterLevel,
13+
)
614

715
_LOGGER = logging.getLogger(__name__)
816

@@ -31,3 +39,55 @@ def process_wohumidifier(
3139
"level": data[4],
3240
"switchMode": True,
3341
}
42+
43+
44+
def process_evaporative_humidifier(
45+
data: bytes | None, mfr_data: bytes | None
46+
) -> dict[str, bool | int]:
47+
"""Process WoHumi services data."""
48+
if mfr_data is None:
49+
return {
50+
"isOn": None,
51+
"mode": None,
52+
"target_humidity": None,
53+
"child_lock": None,
54+
"over_humidify_protection": None,
55+
"tank_removed": None,
56+
"tilted_alert": None,
57+
"filter_missing": None,
58+
"humidity": None,
59+
"temperature": None,
60+
"filter_run_time": None,
61+
"filter_alert": None,
62+
"water_level": None,
63+
}
64+
65+
is_on = bool(mfr_data[7] & 0b10000000)
66+
mode = HumidifierMode(mfr_data[7] & 0b00001111)
67+
filter_run_time = timedelta(hours=int.from_bytes(mfr_data[12:14], byteorder="big"))
68+
has_humidity = bool(mfr_data[9] & 0b10000000)
69+
has_temperature = bool(mfr_data[10] & 0b10000000)
70+
is_tank_removed = bool(mfr_data[8] & 0b00000100)
71+
return {
72+
"isOn": is_on,
73+
"mode": mode if is_on else None,
74+
"target_humidity": (mfr_data[16] & 0b01111111)
75+
if is_on and mode in TARGET_HUMIDITY_MODES
76+
else None,
77+
"child_lock": bool(mfr_data[8] & 0b00100000),
78+
"over_humidify_protection": bool(mfr_data[8] & 0b10000000)
79+
if is_on and mode in OVER_HUMIDIFY_PROTECTION_MODES
80+
else None,
81+
"tank_removed": is_tank_removed,
82+
"tilted_alert": bool(mfr_data[8] & 0b00000010),
83+
"filter_missing": bool(mfr_data[8] & 0b00000001),
84+
"humidity": (mfr_data[9] & 0b01111111) if has_humidity else None,
85+
"temperature": float(mfr_data[10] & 0b01111111) + float(mfr_data[11] >> 4) / 10
86+
if has_temperature
87+
else None,
88+
"filter_run_time": filter_run_time,
89+
"filter_alert": filter_run_time.days >= 10,
90+
"water_level": HumidifierWaterLevel(mfr_data[11] & 0b00000011)
91+
if not is_tank_removed
92+
else None,
93+
}

switchbot/const/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,4 @@ class SwitchbotModel(StrEnum):
6262
RELAY_SWITCH_1PM = "Relay Switch 1PM"
6363
RELAY_SWITCH_1 = "Relay Switch 1"
6464
REMOTE = "WoRemote"
65+
EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
5+
6+
class HumidifierMode(Enum):
7+
HIGH = 1
8+
MEDIUM = 2
9+
LOW = 3
10+
QUIET = 4
11+
TARGET_HUMIDITY = 5
12+
SLEEP = 6
13+
AUTO = 7
14+
DRYING_FILTER = 8
15+
16+
17+
class HumidifierWaterLevel(Enum):
18+
EMPTY = 0
19+
LOW = 1
20+
MEDIUM = 2
21+
HIGH = 3
22+
23+
24+
OVER_HUMIDIFY_PROTECTION_MODES = {
25+
HumidifierMode.QUIET,
26+
HumidifierMode.LOW,
27+
HumidifierMode.MEDIUM,
28+
HumidifierMode.HIGH,
29+
}
30+
31+
TARGET_HUMIDITY_MODES = {
32+
HumidifierMode.SLEEP,
33+
HumidifierMode.TARGET_HUMIDITY,
34+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import logging
2+
from typing import Any
3+
4+
from bleak.backends.device import BLEDevice
5+
6+
from ..const import SwitchbotModel
7+
from ..const.evaporative_humidifier import (
8+
TARGET_HUMIDITY_MODES,
9+
HumidifierMode,
10+
HumidifierWaterLevel,
11+
)
12+
from ..models import SwitchBotAdvertisement
13+
from .device import SwitchbotEncryptedDevice
14+
15+
_LOGGER = logging.getLogger(__name__)
16+
17+
COMMAND_HEADER = "57"
18+
COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103"
19+
COMMAND_TURN_ON = f"{COMMAND_HEADER}0f430101"
20+
COMMAND_TURN_OFF = f"{COMMAND_HEADER}0f430100"
21+
COMMAND_CHILD_LOCK_ON = f"{COMMAND_HEADER}0f430501"
22+
COMMAND_CHILD_LOCK_OFF = f"{COMMAND_HEADER}0f430500"
23+
COMMAND_AUTO_DRY_ON = f"{COMMAND_HEADER}0f430a01"
24+
COMMAND_AUTO_DRY_OFF = f"{COMMAND_HEADER}0f430a02"
25+
COMMAND_SET_MODE = f"{COMMAND_HEADER}0f4302"
26+
COMMAND_GET_BASIC_INFO = f"{COMMAND_HEADER}000300"
27+
28+
MODES_COMMANDS = {
29+
HumidifierMode.HIGH: "010100",
30+
HumidifierMode.MEDIUM: "010200",
31+
HumidifierMode.LOW: "010300",
32+
HumidifierMode.QUIET: "010400",
33+
HumidifierMode.TARGET_HUMIDITY: "0200",
34+
HumidifierMode.SLEEP: "0300",
35+
HumidifierMode.AUTO: "040000",
36+
}
37+
38+
39+
class SwitchbotEvaporativeHumidifier(SwitchbotEncryptedDevice):
40+
"""Representation of a Switchbot Evaporative Humidifier"""
41+
42+
def __init__(
43+
self,
44+
device: BLEDevice,
45+
key_id: str,
46+
encryption_key: str,
47+
interface: int = 0,
48+
model: SwitchbotModel = SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
49+
**kwargs: Any,
50+
) -> None:
51+
self._force_next_update = False
52+
super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
53+
54+
@classmethod
55+
async def verify_encryption_key(
56+
cls,
57+
device: BLEDevice,
58+
key_id: str,
59+
encryption_key: str,
60+
model: SwitchbotModel = SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
61+
**kwargs: Any,
62+
) -> bool:
63+
return await super().verify_encryption_key(
64+
device, key_id, encryption_key, model, **kwargs
65+
)
66+
67+
def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None:
68+
"""Update device data from advertisement."""
69+
super().update_from_advertisement(advertisement)
70+
_LOGGER.debug(
71+
"%s: update advertisement: %s",
72+
self.name,
73+
advertisement,
74+
)
75+
76+
async def _get_basic_info(self) -> bytes | None:
77+
"""Return basic info of device."""
78+
_data = await self._send_command(
79+
key=COMMAND_GET_BASIC_INFO, retry=self._retry_count
80+
)
81+
82+
if _data in (b"\x07", b"\x00"):
83+
_LOGGER.error("Unsuccessful, please try again")
84+
return None
85+
86+
return _data
87+
88+
async def get_basic_info(self) -> dict[str, Any] | None:
89+
"""Get device basic settings."""
90+
if not (_data := await self._get_basic_info()):
91+
return None
92+
93+
# Not 100% sure about this data, will verify once a firmware update is available
94+
return {
95+
"firmware": _data[2] / 10.0,
96+
}
97+
98+
async def turn_on(self) -> bool:
99+
"""Turn device on."""
100+
result = await self._send_command(COMMAND_TURN_ON)
101+
if ok := self._check_command_result(result, 0, {1}):
102+
self._override_state({"isOn": True})
103+
self._fire_callbacks()
104+
return ok
105+
106+
async def turn_off(self) -> bool:
107+
"""Turn device off."""
108+
result = await self._send_command(COMMAND_TURN_OFF)
109+
if ok := self._check_command_result(result, 0, {1}):
110+
self._override_state({"isOn": False})
111+
self._fire_callbacks()
112+
return ok
113+
114+
async def set_mode(
115+
self, mode: HumidifierMode, target_humidity: int | None = None
116+
) -> bool:
117+
"""Set device mode."""
118+
if mode == HumidifierMode.DRYING_FILTER:
119+
return await self.start_drying_filter()
120+
elif mode not in MODES_COMMANDS:
121+
raise ValueError("Invalid mode")
122+
123+
command = COMMAND_SET_MODE + MODES_COMMANDS[mode]
124+
if mode in TARGET_HUMIDITY_MODES:
125+
if target_humidity is None:
126+
raise TypeError("target_humidity is required")
127+
command += f"{target_humidity:02x}"
128+
result = await self._send_command(command)
129+
if ok := self._check_command_result(result, 0, {1}):
130+
self._override_state({"mode": mode})
131+
if mode == HumidifierMode.TARGET_HUMIDITY and target_humidity is not None:
132+
self._override_state({"target_humidity": target_humidity})
133+
self._fire_callbacks()
134+
return ok
135+
136+
async def set_child_lock(self, enabled: bool) -> bool:
137+
"""Set child lock."""
138+
result = await self._send_command(
139+
COMMAND_CHILD_LOCK_ON if enabled else COMMAND_CHILD_LOCK_OFF
140+
)
141+
if ok := self._check_command_result(result, 0, {1}):
142+
self._override_state({"child_lock": enabled})
143+
self._fire_callbacks()
144+
return ok
145+
146+
async def start_drying_filter(self):
147+
"""Start drying filter."""
148+
result = await self._send_command(COMMAND_TURN_ON + "08")
149+
if ok := self._check_command_result(result, 0, {1}):
150+
self._override_state({"mode": HumidifierMode.DRYING_FILTER})
151+
self._fire_callbacks()
152+
return ok
153+
154+
async def stop_drying_filter(self):
155+
"""Stop drying filter."""
156+
result = await self._send_command(COMMAND_TURN_OFF)
157+
if ok := self._check_command_result(result, 0, {0}):
158+
self._override_state({"isOn": False, "mode": None})
159+
self._fire_callbacks()
160+
return ok
161+
162+
def is_on(self) -> bool | None:
163+
"""Return state from cache."""
164+
return self._get_adv_value("isOn")
165+
166+
def get_mode(self) -> HumidifierMode | None:
167+
"""Return state from cache."""
168+
return self._get_adv_value("mode")
169+
170+
def is_child_lock_enabled(self) -> bool | None:
171+
"""Return state from cache."""
172+
return self._get_adv_value("child_lock")
173+
174+
def is_over_humidify_protection_enabled(self) -> bool | None:
175+
"""Return state from cache."""
176+
return self._get_adv_value("over_humidify_protection")
177+
178+
def is_tank_removed(self) -> bool | None:
179+
"""Return state from cache."""
180+
return self._get_adv_value("tank_removed")
181+
182+
def is_filter_missing(self) -> bool | None:
183+
"""Return state from cache."""
184+
return self._get_adv_value("filter_missing")
185+
186+
def is_filter_alert_on(self) -> bool | None:
187+
"""Return state from cache."""
188+
return self._get_adv_value("filter_alert")
189+
190+
def is_tilted_alert_on(self) -> bool | None:
191+
"""Return state from cache."""
192+
return self._get_adv_value("tilted_alert")
193+
194+
def get_water_level(self) -> HumidifierWaterLevel | None:
195+
"""Return state from cache."""
196+
return self._get_adv_value("water_level")
197+
198+
def get_filter_run_time(self) -> int | None:
199+
"""Return state from cache."""
200+
return self._get_adv_value("filter_run_time")
201+
202+
def get_target_humidity(self) -> int | None:
203+
"""Return state from cache."""
204+
return self._get_adv_value("target_humidity")
205+
206+
def get_humidity(self) -> int | None:
207+
"""Return state from cache."""
208+
return self._get_adv_value("humidity")
209+
210+
def get_temperature(self) -> float | None:
211+
"""Return state from cache."""
212+
return self._get_adv_value("temperature")

switchbot/discovery.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ async def get_keypads(self) -> dict[str, SwitchBotAdvertisement]:
131131
"""Return all WoKeypad/Keypad devices with services data."""
132132
return await self._get_devices_by_model("y")
133133

134+
async def get_humidifiers(self) -> dict[str, SwitchBotAdvertisement]:
135+
"""Return all humidifier devices with services data."""
136+
humidifiers = await self._get_devices_by_model("e")
137+
evaporative_humidifiers = await self._get_devices_by_model("#")
138+
return {**humidifiers, **evaporative_humidifiers}
139+
134140
async def get_device_data(
135141
self, address: str
136142
) -> dict[str, SwitchBotAdvertisement] | None:

0 commit comments

Comments
 (0)