PySwitchbot 0.56.1__tar.gz → 0.57.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/PKG-INFO +1 -1
  2. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/PySwitchbot.egg-info/PKG-INFO +1 -1
  3. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/PySwitchbot.egg-info/SOURCES.txt +3 -0
  4. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/setup.py +1 -1
  5. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/__init__.py +6 -0
  6. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parser.py +9 -9
  7. pyswitchbot-0.57.1/switchbot/adv_parsers/humidifier.py +93 -0
  8. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/const/__init__.py +1 -0
  9. pyswitchbot-0.57.1/switchbot/const/evaporative_humidifier.py +34 -0
  10. pyswitchbot-0.57.1/switchbot/devices/evaporative_humidifier.py +212 -0
  11. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/discovery.py +6 -0
  12. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/tests/test_adv_parser.py +0 -25
  13. pyswitchbot-0.57.1/tests/test_evaporative_humidifier.py +202 -0
  14. pyswitchbot-0.56.1/switchbot/adv_parsers/humidifier.py +0 -33
  15. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/LICENSE +0 -0
  16. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/MANIFEST.in +0 -0
  17. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  18. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/PySwitchbot.egg-info/requires.txt +0 -0
  19. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/PySwitchbot.egg-info/top_level.txt +0 -0
  20. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/README.md +0 -0
  21. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/pyproject.toml +0 -0
  22. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/setup.cfg +0 -0
  23. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/__init__.py +0 -0
  24. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/blind_tilt.py +0 -0
  25. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/bot.py +0 -0
  26. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/bulb.py +0 -0
  27. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/ceiling_light.py +0 -0
  28. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/contact.py +0 -0
  29. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/curtain.py +0 -0
  30. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/hub2.py +0 -0
  31. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/keypad.py +0 -0
  32. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/leak.py +0 -0
  33. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/light_strip.py +0 -0
  34. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/lock.py +0 -0
  35. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/meter.py +0 -0
  36. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/motion.py +0 -0
  37. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/plug.py +0 -0
  38. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/relay_switch.py +0 -0
  39. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/adv_parsers/remote.py +0 -0
  40. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/api_config.py +0 -0
  41. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/const/lock.py +0 -0
  42. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/__init__.py +0 -0
  43. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/base_cover.py +0 -0
  44. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/base_light.py +0 -0
  45. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/blind_tilt.py +0 -0
  46. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/bot.py +0 -0
  47. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/bulb.py +0 -0
  48. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/ceiling_light.py +0 -0
  49. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/contact.py +0 -0
  50. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/curtain.py +0 -0
  51. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/device.py +0 -0
  52. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/humidifier.py +0 -0
  53. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/keypad.py +0 -0
  54. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/light_strip.py +0 -0
  55. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/lock.py +0 -0
  56. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/meter.py +0 -0
  57. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/motion.py +0 -0
  58. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/plug.py +0 -0
  59. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/devices/relay_switch.py +0 -0
  60. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/enum.py +0 -0
  61. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/helpers.py +0 -0
  62. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/switchbot/models.py +0 -0
  63. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/tests/test_base_cover.py +0 -0
  64. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/tests/test_blind_tilt.py +0 -0
  65. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/tests/test_curtain.py +0 -0
  66. {pyswitchbot-0.56.1 → pyswitchbot-0.57.1}/tests/test_relay_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: PySwitchbot
3
- Version: 0.56.1
3
+ Version: 0.57.1
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: PySwitchbot
3
- Version: 0.56.1
3
+ Version: 0.57.1
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -34,6 +34,7 @@ switchbot/adv_parsers/plug.py
34
34
  switchbot/adv_parsers/relay_switch.py
35
35
  switchbot/adv_parsers/remote.py
36
36
  switchbot/const/__init__.py
37
+ switchbot/const/evaporative_humidifier.py
37
38
  switchbot/const/lock.py
38
39
  switchbot/devices/__init__.py
39
40
  switchbot/devices/base_cover.py
@@ -45,6 +46,7 @@ switchbot/devices/ceiling_light.py
45
46
  switchbot/devices/contact.py
46
47
  switchbot/devices/curtain.py
47
48
  switchbot/devices/device.py
49
+ switchbot/devices/evaporative_humidifier.py
48
50
  switchbot/devices/humidifier.py
49
51
  switchbot/devices/keypad.py
50
52
  switchbot/devices/light_strip.py
@@ -57,4 +59,5 @@ tests/test_adv_parser.py
57
59
  tests/test_base_cover.py
58
60
  tests/test_blind_tilt.py
59
61
  tests/test_curtain.py
62
+ tests/test_evaporative_humidifier.py
60
63
  tests/test_relay_switch.py
@@ -20,7 +20,7 @@ setup(
20
20
  "cryptography>=39.0.0",
21
21
  "pyOpenSSL>=23.0.0",
22
22
  ],
23
- version="0.56.1",
23
+ version="0.57.1",
24
24
  description="A library to communicate with Switchbot",
25
25
  long_description=long_description,
26
26
  long_description_content_type="text/markdown",
@@ -23,6 +23,7 @@ from .devices.bulb import SwitchbotBulb
23
23
  from .devices.ceiling_light import SwitchbotCeilingLight
24
24
  from .devices.curtain import SwitchbotCurtain
25
25
  from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice
26
+ from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
26
27
  from .devices.humidifier import SwitchbotHumidifier
27
28
  from .devices.light_strip import SwitchbotLightStrip
28
29
  from .devices.lock import SwitchbotLock
@@ -37,6 +38,7 @@ __all__ = [
37
38
  "LockStatus",
38
39
  "SwitchBotAdvertisement",
39
40
  "Switchbot",
41
+ "Switchbot",
40
42
  "SwitchbotAccountConnectionError",
41
43
  "SwitchbotApiError",
42
44
  "SwitchbotAuthenticationError",
@@ -47,13 +49,17 @@ __all__ = [
47
49
  "SwitchbotCurtain",
48
50
  "SwitchbotDevice",
49
51
  "SwitchbotEncryptedDevice",
52
+ "SwitchbotEvaporativeHumidifier",
50
53
  "SwitchbotHumidifier",
51
54
  "SwitchbotLightStrip",
52
55
  "SwitchbotLock",
53
56
  "SwitchbotModel",
57
+ "SwitchbotModel",
58
+ "SwitchbotPlugMini",
54
59
  "SwitchbotPlugMini",
55
60
  "SwitchbotRelaySwitch",
56
61
  "SwitchbotSupportedType",
62
+ "SwitchbotSupportedType",
57
63
  "close_stale_connections",
58
64
  "close_stale_connections_by_address",
59
65
  "get_device",
@@ -17,7 +17,7 @@ from .adv_parsers.ceiling_light import process_woceiling
17
17
  from .adv_parsers.contact import process_wocontact
18
18
  from .adv_parsers.curtain import process_wocurtain
19
19
  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
21
21
  from .adv_parsers.keypad import process_wokeypad
22
22
  from .adv_parsers.leak import process_leak
23
23
  from .adv_parsers.light_strip import process_wostrip
@@ -41,8 +41,6 @@ SERVICE_DATA_ORDER = (
41
41
  )
42
42
  MFR_DATA_ORDER = (2409, 741, 89)
43
43
 
44
- APPLE_MANUFACTURER_ID = 76
45
-
46
44
 
47
45
  class SwitchbotSupportedType(TypedDict):
48
46
  """Supported type of Switchbot."""
@@ -164,6 +162,12 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
164
162
  "manufacturer_id": 741,
165
163
  "manufacturer_data_length": 6,
166
164
  },
165
+ "#": {
166
+ "modelName": SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
167
+ "modelFriendlyName": "Evaporative Humidifier",
168
+ "func": process_evaporative_humidifier,
169
+ "manufacturer_id": 2409,
170
+ },
167
171
  "o": {
168
172
  "modelName": SwitchbotModel.LOCK,
169
173
  "modelFriendlyName": "Lock",
@@ -244,14 +248,10 @@ def parse_advertisement_data(
244
248
 
245
249
  _mfr_data = None
246
250
  _mfr_id = None
247
- manufacturer_data = advertisement_data.manufacturer_data
248
- if APPLE_MANUFACTURER_ID in manufacturer_data:
249
- return None
250
-
251
251
  for mfr_id in MFR_DATA_ORDER:
252
- if mfr_id in manufacturer_data:
252
+ if mfr_id in advertisement_data.manufacturer_data:
253
253
  _mfr_id = mfr_id
254
- _mfr_data = manufacturer_data[mfr_id]
254
+ _mfr_data = advertisement_data.manufacturer_data[mfr_id]
255
255
  break
256
256
 
257
257
  if _mfr_data is None and _service_data is None:
@@ -0,0 +1,93 @@
1
+ """Humidifier adv parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ 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
+ )
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ # mfr_data: 943cc68d3d2e
18
+ # data: 650000cd802b6300
19
+ # data: 650000cd802b6300
20
+ # data: 658000c9802b6300
21
+
22
+
23
+ # Low: 658000c5222b6300
24
+ # Med: 658000c5432b6300
25
+ # High: 658000c5642b6300
26
+ def process_wohumidifier(
27
+ data: bytes | None, mfr_data: bytes | None
28
+ ) -> dict[str, bool | int]:
29
+ """Process WoHumi services data."""
30
+ if data is None:
31
+ return {
32
+ "isOn": None,
33
+ "level": None,
34
+ "switchMode": True,
35
+ }
36
+
37
+ return {
38
+ "isOn": bool(data[1]),
39
+ "level": data[4],
40
+ "switchMode": True,
41
+ }
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
+ }
@@ -62,3 +62,4 @@ class SwitchbotModel(StrEnum):
62
62
  RELAY_SWITCH_1PM = "Relay Switch 1PM"
63
63
  RELAY_SWITCH_1 = "Relay Switch 1"
64
64
  REMOTE = "WoRemote"
65
+ EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier"
@@ -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
+ }
@@ -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")
@@ -131,6 +131,12 @@ class GetSwitchbotDevices:
131
131
  """Return all WoKeypad/Keypad devices with services data."""
132
132
  return await self._get_devices_by_model("y")
133
133
 
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
+
134
140
  async def get_device_data(
135
141
  self, address: str
136
142
  ) -> dict[str, SwitchBotAdvertisement] | None:
@@ -1970,28 +1970,3 @@ def test_remote_passive() -> None:
1970
1970
  rssi=-97,
1971
1971
  active=False,
1972
1972
  )
1973
-
1974
-
1975
- def test_parse_advertisement_ignores_devices_with_apple_manufacturer_id():
1976
- """Test parse_advertisement_data ignores devices with apple manufacturer id."""
1977
- ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
1978
- adv_data = generate_advertisement_data(
1979
- local_name="WoCurtain",
1980
- manufacturer_data={
1981
- 89: b"\xcc\xf4\xc4\xf9\xacl",
1982
- 2409: b"\xcc\xf4\xc4\xf9\xacl\xe2\x0f\x00\x12\x04",
1983
- 76: b"\x10",
1984
- },
1985
- service_data={
1986
- "00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Yd\x11\x04",
1987
- "0000fd3d-0000-1000-8000-00805f9b34fb": b"c\xc0d\x00\x12\x04",
1988
- },
1989
- service_uuids=[
1990
- "00001800-0000-1000-8000-00805f9b34fb",
1991
- "00001801-0000-1000-8000-00805f9b34fb",
1992
- "cba20d00-224d-11e6-9fb8-0002a5d5c51b",
1993
- ],
1994
- rssi=-2,
1995
- )
1996
- result = parse_advertisement_data(ble_device, adv_data)
1997
- assert result is None
@@ -0,0 +1,202 @@
1
+ import datetime
2
+ from unittest.mock import AsyncMock
3
+
4
+ import pytest
5
+ from bleak.backends.device import BLEDevice
6
+
7
+ from switchbot import SwitchBotAdvertisement, SwitchbotModel
8
+ from switchbot.adv_parsers.humidifier import process_evaporative_humidifier
9
+ from switchbot.const.evaporative_humidifier import HumidifierMode, HumidifierWaterLevel
10
+ from switchbot.devices import evaporative_humidifier
11
+
12
+ from .test_adv_parser import generate_ble_device
13
+
14
+
15
+ def create_device_for_command_testing(init_data: dict | None = None):
16
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
17
+ evaporative_humidifier_device = (
18
+ evaporative_humidifier.SwitchbotEvaporativeHumidifier(
19
+ ble_device, "ff", "ffffffffffffffffffffffffffffffff"
20
+ )
21
+ )
22
+ evaporative_humidifier_device.update_from_advertisement(
23
+ make_advertisement_data(ble_device, init_data)
24
+ )
25
+ return evaporative_humidifier_device
26
+
27
+
28
+ def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None):
29
+ if init_data is None:
30
+ init_data = {}
31
+ """Set advertisement data with defaults."""
32
+ return SwitchBotAdvertisement(
33
+ address="aa:bb:cc:dd:ee:ff",
34
+ data={
35
+ "rawAdvData": b"#\x00\x00\x15\x1c\x00",
36
+ "data": {
37
+ "isOn": False,
38
+ "mode": None,
39
+ "target_humidity": None,
40
+ "child_lock": False,
41
+ "over_humidify_protection": True,
42
+ "tank_removed": False,
43
+ "tilted_alert": False,
44
+ "filter_missing": False,
45
+ "humidity": 51,
46
+ "temperature": 16.8,
47
+ "filter_run_time": datetime.timedelta(days=3, seconds=57600),
48
+ "filter_alert": False,
49
+ "water_level": HumidifierWaterLevel.LOW,
50
+ }
51
+ | init_data,
52
+ "isEncrypted": False,
53
+ "model": "#",
54
+ "modelFriendlyName": "Evaporative Humidifier",
55
+ "modelName": SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
56
+ },
57
+ device=ble_device,
58
+ rssi=-80,
59
+ active=True,
60
+ )
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_process_advertisement():
65
+ data = process_evaporative_humidifier(
66
+ b"#\x00\x00\x15\x1c\x00",
67
+ b"\xd4\x8cIU\x95\xb2\x08\x06\x88\xb3\x90\x81\x00X\x00X2",
68
+ )
69
+
70
+ assert data == {
71
+ "isOn": False,
72
+ "mode": None,
73
+ "target_humidity": None,
74
+ "child_lock": False,
75
+ "over_humidify_protection": None,
76
+ "tank_removed": False,
77
+ "tilted_alert": False,
78
+ "filter_missing": False,
79
+ "humidity": 51,
80
+ "temperature": 16.8,
81
+ "filter_run_time": datetime.timedelta(days=3, seconds=57600),
82
+ "filter_alert": False,
83
+ "water_level": HumidifierWaterLevel.LOW,
84
+ }
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_process_advertisement_empty():
89
+ data = process_evaporative_humidifier(None, None)
90
+
91
+ assert data == {
92
+ "isOn": None,
93
+ "mode": None,
94
+ "target_humidity": None,
95
+ "child_lock": None,
96
+ "over_humidify_protection": None,
97
+ "tank_removed": None,
98
+ "tilted_alert": None,
99
+ "filter_missing": None,
100
+ "humidity": None,
101
+ "temperature": None,
102
+ "filter_run_time": None,
103
+ "filter_alert": None,
104
+ "water_level": None,
105
+ }
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_turn_on():
110
+ device = create_device_for_command_testing({"isOn": False})
111
+ device._send_command = AsyncMock(return_value=b"\x01")
112
+
113
+ assert device.is_on() is False
114
+ await device.turn_on()
115
+ assert device.is_on() is True
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_turn_off():
120
+ device = create_device_for_command_testing({"isOn": True})
121
+ device._send_command = AsyncMock(return_value=b"\x01")
122
+
123
+ assert device.is_on() is True
124
+ await device.turn_off()
125
+ assert device.is_on() is False
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_set_mode():
130
+ device = create_device_for_command_testing(
131
+ {"isOn": True, "mode": HumidifierMode.LOW}
132
+ )
133
+ device._send_command = AsyncMock(return_value=b"\x01")
134
+
135
+ assert device.get_mode() is HumidifierMode.LOW
136
+ await device.set_mode(HumidifierMode.AUTO)
137
+ assert device.get_mode() is HumidifierMode.AUTO
138
+
139
+ await device.set_mode(HumidifierMode.TARGET_HUMIDITY, 60)
140
+ assert device.get_mode() is HumidifierMode.TARGET_HUMIDITY
141
+ assert device.get_target_humidity() == 60
142
+
143
+ await device.set_mode(HumidifierMode.DRYING_FILTER)
144
+ assert device.get_mode() is HumidifierMode.DRYING_FILTER
145
+
146
+ with pytest.raises(ValueError):
147
+ await device.set_mode(0)
148
+
149
+ with pytest.raises(TypeError):
150
+ await device.set_mode(HumidifierMode.TARGET_HUMIDITY)
151
+
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_set_child_lock():
155
+ device = create_device_for_command_testing({"child_lock": False})
156
+ device._send_command = AsyncMock(return_value=b"\x01")
157
+
158
+ assert device.is_child_lock_enabled() is False
159
+ await device.set_child_lock(True)
160
+ assert device.is_child_lock_enabled() is True
161
+
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_start_drying_filter():
165
+ device = create_device_for_command_testing(
166
+ {"isOn": True, "mode": HumidifierMode.AUTO}
167
+ )
168
+ device._send_command = AsyncMock(return_value=b"\x01")
169
+
170
+ assert device.get_mode() is HumidifierMode.AUTO
171
+ await device.start_drying_filter()
172
+ assert device.get_mode() is HumidifierMode.DRYING_FILTER
173
+
174
+
175
+ @pytest.mark.asyncio
176
+ async def test_stop_drying_filter():
177
+ device = create_device_for_command_testing(
178
+ {"isOn": True, "mode": HumidifierMode.DRYING_FILTER}
179
+ )
180
+ device._send_command = AsyncMock(return_value=b"\x00")
181
+
182
+ assert device.is_on() is True
183
+ assert device.get_mode() is HumidifierMode.DRYING_FILTER
184
+ await device.stop_drying_filter()
185
+ assert device.is_on() is False
186
+ assert device.get_mode() is None
187
+
188
+
189
+ @pytest.mark.asyncio
190
+ async def test_attributes():
191
+ device = create_device_for_command_testing()
192
+ device._send_command = AsyncMock(return_value=b"\x01")
193
+
194
+ assert device.is_over_humidify_protection_enabled() is True
195
+ assert device.is_tank_removed() is False
196
+ assert device.is_filter_missing() is False
197
+ assert device.is_filter_alert_on() is False
198
+ assert device.is_tilted_alert_on() is False
199
+ assert device.get_water_level() is HumidifierWaterLevel.LOW
200
+ assert device.get_filter_run_time() == datetime.timedelta(days=3, seconds=57600)
201
+ assert device.get_humidity() == 51
202
+ assert device.get_temperature() == 16.8
@@ -1,33 +0,0 @@
1
- """Humidifier adv parser."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
-
7
- _LOGGER = logging.getLogger(__name__)
8
-
9
- # mfr_data: 943cc68d3d2e
10
- # data: 650000cd802b6300
11
- # data: 650000cd802b6300
12
- # data: 658000c9802b6300
13
-
14
-
15
- # Low: 658000c5222b6300
16
- # Med: 658000c5432b6300
17
- # High: 658000c5642b6300
18
- def process_wohumidifier(
19
- data: bytes | None, mfr_data: bytes | None
20
- ) -> dict[str, bool | int]:
21
- """Process WoHumi services data."""
22
- if data is None:
23
- return {
24
- "isOn": None,
25
- "level": None,
26
- "switchMode": True,
27
- }
28
-
29
- return {
30
- "isOn": bool(data[1]),
31
- "level": data[4],
32
- "switchMode": True,
33
- }
File without changes
File without changes
File without changes
File without changes